From e3b29488bacb7868358ac36828e33208a196fa8d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:27:49 +0000 Subject: [PATCH 1/2] Fix missing arguments in OpenCode generator - Add 'arguments' to the list of fields copied in `generate_opencode_markdown`. - Ensure arguments are preserved when converting Claude Code commands to OpenCode format. This fixes a bug where command arguments were being filtered out during the sync process. Co-authored-by: tstapler <3860386+tstapler@users.noreply.github.com> --- stapler-scripts/sync-claude-to-opencode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stapler-scripts/sync-claude-to-opencode.py b/stapler-scripts/sync-claude-to-opencode.py index f8de54a..5997ea6 100755 --- a/stapler-scripts/sync-claude-to-opencode.py +++ b/stapler-scripts/sync-claude-to-opencode.py @@ -322,7 +322,7 @@ def generate_opencode_markdown(self, agent_data: Dict[str, Any]) -> str: frontmatter = {} # Copy relevant fields - for key in ['description', 'mode', 'model', 'temperature', 'tools']: + for key in ['description', 'mode', 'model', 'temperature', 'tools', 'arguments']: if key in agent_data: frontmatter[key] = agent_data[key] From 47462c754e59d827d09fa4ffbbb5aa8c2793b6f4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:26:13 +0000 Subject: [PATCH 2/2] Fix missing arguments and command parsing in llm-sync - Update `OpenCodeTarget.save_commands` and `save_agents` to include `arguments` in metadata. - Restore robust manual frontmatter parsing in `ClaudeSource.load_commands` to handle malformed YAML (e.g. `tools: *`) correctly, mirroring previous behavior. - This applies the fix to the new `llm-sync` package after `sync-claude-to-opencode.py` was removed in master. Co-authored-by: tstapler <3860386+tstapler@users.noreply.github.com> --- .../skills/github-actions-debugging/README.md | 171 ++++ .../skills/github-actions-debugging/SKILL.md | 295 +++++++ .../error-patterns.md | 760 ++++++++++++++++++ .../github-actions-debugging/examples.md | 644 +++++++++++++++ .../resources/error-patterns.json | 215 +++++ .../scripts/parse_workflow_logs.py | 259 ++++++ .vimrc.bundles.local | 2 +- .zshenv | 1 + stapler-scripts/ark-mod-manager/.gitignore | 23 + .../ark-mod-manager/.python-version | 1 + stapler-scripts/ark-mod-manager/README.md | 0 stapler-scripts/ark-mod-manager/diff_utils.py | 81 ++ stapler-scripts/ark-mod-manager/main.py | 456 +++++++++++ .../ark-mod-manager/manage_mods.py | 461 +++++++++++ .../ark-mod-manager/mod_configs.json | 32 + .../ark-mod-manager/mod_mapping.json | 382 +++++++++ .../ark-mod-manager/pyproject.toml | 20 + .../ark-mod-manager/test_manage_mods.py | 67 ++ .../ark-mod-manager/tuning_presets.json | 320 ++++++++ stapler-scripts/ark-mod-manager/uv.lock | 353 ++++++++ .../claude-proxy/providers/bedrock.py | 43 +- stapler-scripts/claude-proxy/requirements.txt | 1 + .../display-switch/.python-version | 1 + stapler-scripts/display-switch/README.md | 0 .../display-switch/display_switch.py | 212 +++++ stapler-scripts/display-switch/main.py | 6 + stapler-scripts/display-switch/pyproject.toml | 12 + .../display-switch/test_display_switch.py | 78 ++ stapler-scripts/display-switch/uv.lock | 79 ++ stapler-scripts/llm-sync/.python-version | 1 + stapler-scripts/llm-sync/main.py | 11 + stapler-scripts/llm-sync/pyproject.toml | 10 + stapler-scripts/llm-sync/src/__init__.py | 0 stapler-scripts/llm-sync/src/cli.py | 78 ++ stapler-scripts/llm-sync/src/core.py | 54 ++ stapler-scripts/llm-sync/src/mappings.py | 56 ++ .../llm-sync/src/sources/__init__.py | 0 .../llm-sync/src/sources/claude.py | 200 +++++ .../llm-sync/src/targets/__init__.py | 0 .../llm-sync/src/targets/gemini.py | 132 +++ .../llm-sync/src/targets/opencode.py | 95 +++ stapler-scripts/llm-sync/uv.lock | 87 ++ stapler-scripts/sync-claude-to-opencode.py | 449 ----------- 43 files changed, 5682 insertions(+), 466 deletions(-) create mode 100644 .claude/skills/github-actions-debugging/README.md create mode 100644 .claude/skills/github-actions-debugging/SKILL.md create mode 100644 .claude/skills/github-actions-debugging/error-patterns.md create mode 100644 .claude/skills/github-actions-debugging/examples.md create mode 100644 .claude/skills/github-actions-debugging/resources/error-patterns.json create mode 100755 .claude/skills/github-actions-debugging/scripts/parse_workflow_logs.py create mode 100644 stapler-scripts/ark-mod-manager/.gitignore create mode 100644 stapler-scripts/ark-mod-manager/.python-version create mode 100644 stapler-scripts/ark-mod-manager/README.md create mode 100644 stapler-scripts/ark-mod-manager/diff_utils.py create mode 100644 stapler-scripts/ark-mod-manager/main.py create mode 100644 stapler-scripts/ark-mod-manager/manage_mods.py create mode 100644 stapler-scripts/ark-mod-manager/mod_configs.json create mode 100644 stapler-scripts/ark-mod-manager/mod_mapping.json create mode 100644 stapler-scripts/ark-mod-manager/pyproject.toml create mode 100644 stapler-scripts/ark-mod-manager/test_manage_mods.py create mode 100644 stapler-scripts/ark-mod-manager/tuning_presets.json create mode 100644 stapler-scripts/ark-mod-manager/uv.lock create mode 100644 stapler-scripts/display-switch/.python-version create mode 100644 stapler-scripts/display-switch/README.md create mode 100755 stapler-scripts/display-switch/display_switch.py create mode 100644 stapler-scripts/display-switch/main.py create mode 100644 stapler-scripts/display-switch/pyproject.toml create mode 100644 stapler-scripts/display-switch/test_display_switch.py create mode 100644 stapler-scripts/display-switch/uv.lock create mode 100644 stapler-scripts/llm-sync/.python-version create mode 100644 stapler-scripts/llm-sync/main.py create mode 100644 stapler-scripts/llm-sync/pyproject.toml create mode 100644 stapler-scripts/llm-sync/src/__init__.py create mode 100644 stapler-scripts/llm-sync/src/cli.py create mode 100644 stapler-scripts/llm-sync/src/core.py create mode 100644 stapler-scripts/llm-sync/src/mappings.py create mode 100644 stapler-scripts/llm-sync/src/sources/__init__.py create mode 100644 stapler-scripts/llm-sync/src/sources/claude.py create mode 100644 stapler-scripts/llm-sync/src/targets/__init__.py create mode 100644 stapler-scripts/llm-sync/src/targets/gemini.py create mode 100644 stapler-scripts/llm-sync/src/targets/opencode.py create mode 100644 stapler-scripts/llm-sync/uv.lock delete mode 100755 stapler-scripts/sync-claude-to-opencode.py diff --git a/.claude/skills/github-actions-debugging/README.md b/.claude/skills/github-actions-debugging/README.md new file mode 100644 index 0000000..4935d26 --- /dev/null +++ b/.claude/skills/github-actions-debugging/README.md @@ -0,0 +1,171 @@ +# GitHub Actions Debugging Skill + +Debug GitHub Actions workflow failures by analyzing logs, identifying error patterns, and providing actionable solutions. + +## Installation + +This skill is automatically discovered by OpenCode/Claude from `~/.claude/skills/`. + +**Verify installation:** +```bash +ls ~/.claude/skills/github-actions-debugging/ +``` + +Should show: +- `SKILL.md` - Core debugging instructions +- `error-patterns.md` - Comprehensive error database +- `examples.md` - Step-by-step debugging walkthroughs +- `scripts/` - Executable tools +- `resources/` - Machine-readable data +- `README.md` - This file + +## Usage + +This skill is automatically loaded by Claude when debugging GitHub Actions failures. + +**Triggers:** +- Workflow failures +- Job timeouts +- CI/CD errors +- Action failures +- Runner errors +- Log analysis requests + +**Example tasks:** +- "Debug this GitHub Actions workflow failure" +- "Why is my CI build timing out?" +- "Fix the permission error in my workflow" +- "Analyze these workflow logs and identify the root cause" + +## Structure + +### Core Files + +**`SKILL.md`** (3,500 tokens) +- 5-phase debugging methodology +- Quick reference table of 20 most common errors +- Tool selection guidance +- Output format requirements +- Integration with other skills/agents + +**`error-patterns.md`** (2,000 tokens) +- Comprehensive database of 100+ error patterns +- Categorized by: Syntax, Dependency, Environment, Permission, Timeout, Network, Docker +- Each pattern includes: signature, causes, fixes, prevention + +**`examples.md`** (1,500 tokens) +- 7 complete debugging walkthroughs +- Real-world scenarios with solutions +- Demonstrates systematic methodology + +### Scripts + +**`scripts/parse_workflow_logs.py`** (600 tokens) +- Automated log parser for large files (>500 lines) +- Extracts errors, categorizes, suggests fixes +- Outputs structured JSON report +- Dual-purpose: executable + documentation + +**Usage:** +```bash +# Parse log file +python scripts/parse_workflow_logs.py workflow.log + +# Parse from stdin +gh run view 12345 --log | python scripts/parse_workflow_logs.py + +# Output format +{ + "summary": { + "total_errors": 3, + "categories": {"dependency": 2, "timeout": 1}, + "critical_count": 2 + }, + "errors": [...] +} +``` + +### Resources + +**`resources/error-patterns.json`** (400 tokens) +- Machine-readable error pattern database +- Used for programmatic error matching +- JSON format for easy parsing + +## Token Efficiency + +The skill uses progressive disclosure: + +| Load Level | Tokens | When Loaded | +|------------|--------|-------------| +| Metadata | 50 | Always (auto-discovery) | +| Core SKILL.md | 3,500 | When skill activated | +| error-patterns.md | 2,000 | Unknown errors | +| examples.md | 1,500 | Complex scenarios | +| Scripts | 600 | Large log files | +| Resources | 400 | Programmatic matching | + +**Typical usage:** 3,500-5,500 tokens (core + 1-2 additional files) + +## Security + +✅ **No hardcoded secrets** - All scripts use environment variables +✅ **Input sanitization** - Safe regex and file handling +✅ **Read-only operations** - No file modifications by default +✅ **No external connections** - Operates on local files only + +## Error Categories + +The skill categorizes errors into: + +- **Syntax** - YAML errors, invalid workflow configuration +- **Dependency** - npm, pip, go, cargo dependency issues +- **Environment** - Missing tools, files, configuration +- **Permission** - Token scopes, SSH keys, SAML SSO +- **Timeout** - Job timeouts, OOM kills +- **Network** - DNS, rate limiting, service outages +- **Docker** - Build failures, image issues + +## Integration + +**Works with existing skills/agents:** +- `github-pr` - PR workflows and status checks +- `github-debugger` - Specialized debugging beyond logs + +**Delegates to github-pr when:** +- Failure related to PR workflow +- Need to analyze PR comments +- CI check is part of broader PR debugging + +**Delegates to github-debugger when:** +- Application-level errors vs. CI/CD errors +- Complex multi-repo scenarios + +## Version History + +- **v1.0.0** (2026-01-04): Initial release + - 5-phase debugging methodology + - 20+ common error patterns + - 100+ comprehensive error database + - 7 example walkthroughs + - Python log parser script + - JSON error pattern database + +## Contributing + +Improvements welcome! Common contributions: + +- **New error patterns** - Add to `error-patterns.md` and `resources/error-patterns.json` +- **Example scenarios** - Add to `examples.md` +- **Script enhancements** - Improve `parse_workflow_logs.py` +- **Documentation** - Clarify instructions in `SKILL.md` + +## License + +Part of Claude skills collection - use freely in your projects. + +## Resources + +- **GitHub Actions Docs**: https://docs.github.com/en/actions +- **Runner Images**: https://github.com/actions/runner-images +- **Community Forum**: https://github.community/c/code-to-cloud/github-actions/41 diff --git a/.claude/skills/github-actions-debugging/SKILL.md b/.claude/skills/github-actions-debugging/SKILL.md new file mode 100644 index 0000000..96f259a --- /dev/null +++ b/.claude/skills/github-actions-debugging/SKILL.md @@ -0,0 +1,295 @@ +--- +name: github-actions-debugging +description: Debug GitHub Actions workflow failures by analyzing logs, identifying error patterns (syntax errors, dependency issues, environment problems, timeouts, permissions), and providing actionable solutions. Use when CI/CD workflows fail, jobs timeout, or actions produce unexpected errors. +--- + +# GitHub Actions Debugging Skill + +You are a GitHub Actions debugging specialist with deep expertise in identifying, diagnosing, and resolving workflow failures across the entire CI/CD pipeline. + +## Core Mission + +Systematically analyze GitHub Actions workflow failures, identify root causes through log analysis and error pattern recognition, and provide specific, actionable solutions that resolve issues quickly. Your goal is to minimize developer debugging time by providing precise fixes, not generic troubleshooting steps. + +## Debugging Methodology + +Apply this 5-phase systematic approach to every workflow failure: + +### Phase 1: Failure Context Gathering +**Actions:** +- Identify failed job(s) and step(s) from workflow summary +- Determine workflow trigger (push, PR, schedule, manual) +- Check runner type (ubuntu-latest, windows, macos, self-hosted) +- Note relevant context: PR from fork, matrix build, composite action + +**Tools:** +- `read` workflow file (.github/workflows/*.yml) +- `grep` for job/step definitions +- `bash` to check git context if needed + +**Output:** Structured summary of failure context + +### Phase 2: Log Analysis +**Actions:** +- Extract error messages with surrounding context (±10 lines) +- Identify error signatures (exit codes, error prefixes) +- Locate first occurrence of failure (cascading errors vs. root cause) +- Check for warnings that preceded failure + +**Tools:** +- `grep` with pattern matching for error keywords +- `pty_read` with pattern filtering for large logs +- `scripts/parse_workflow_logs.py` for logs >500 lines + +**Error Keywords to Search:** +``` +Error|ERROR|FAIL|Failed|failed|fatal|FATAL| +npm ERR!|pip error|go: |cargo error| +Permission denied|timeout|timed out| +exit code|returned non-zero| +``` + +**Output:** List of errors with line numbers and context + +### Phase 3: Error Categorization +**Actions:** +- Match errors against known pattern database (see Quick Reference below) +- Classify by category: Syntax, Dependency, Environment, Permission, Timeout, Network +- Determine severity: Critical (blocks workflow), Warning (degraded) +- Identify if error is intermittent or deterministic + +**Tools:** +- Pattern matching against Quick Reference table +- `read error-patterns.md` for comprehensive database (if needed) +- `resources/error-patterns.json` for programmatic matching + +**Output:** Categorized error list with severity + +### Phase 4: Root Cause Analysis +**Actions:** +- Trace error to source: workflow syntax, action version, dependency, environment +- Check for recent changes: workflow modifications, dependency updates, GitHub Actions platform changes +- Identify configuration mismatches: secrets, environment variables, runner capabilities +- Consider timing issues: race conditions, timeout thresholds, cache invalidation + +**Validation Steps:** +- Verify action versions are valid and compatible +- Check required secrets/variables are configured +- Confirm runner has necessary tools/permissions +- Review dependency lock files for conflicts + +**Output:** Root cause statement with evidence + +### Phase 5: Solution Generation +**Actions:** +- Provide specific fix (not "check your configuration") +- Include code changes with exact syntax +- Explain why fix resolves root cause +- Suggest prevention measures +- Estimate fix complexity (simple/moderate/complex) + +**Solution Format:** +```markdown +## Root Cause +[Specific explanation with evidence] + +## Fix +[Exact changes needed - use code blocks] + +## Why This Works +[Technical explanation] + +## Prevention +[How to avoid in future] + +## Verification +[How to test the fix] +``` + +--- + +## Common Error Patterns - Quick Reference + +Use this table for Phase 3 categorization. For comprehensive patterns, load `error-patterns.md`. + +| Error Signature | Category | Common Cause | Quick Fix | +|-----------------|----------|--------------|-----------| +| `npm ERR! code ERESOLVE` | Dependency | Peer dependency conflict | Add `npm install --legacy-peer-deps` or update conflicting packages | +| `Error: Process completed with exit code 1` (npm ci) | Dependency | Lock file out of sync | Delete `package-lock.json`, regenerate with `npm install` | +| `pip: error: unrecognized arguments` | Dependency | Pip version incompatibility | Pin pip version: `python -m pip install --upgrade pip==23.0` | +| `go: inconsistent vendoring` | Dependency | Go modules out of sync | Run `go mod tidy && go mod vendor` | +| `Permission denied (publickey)` | Permission | SSH key not configured | Add deploy key or use HTTPS with PAT | +| `Resource not accessible by integration` | Permission | Token lacks scope | Update token with required permissions (contents: write, etc.) | +| `Error: HttpError: Not Found` | Permission | Private repo/action access | Add repository access to GITHUB_TOKEN permissions | +| `##[error]Process completed with exit code 137` | Timeout/Resource | OOM killed (memory exhausted) | Reduce memory usage or use larger runner | +| `##[error]The job running on runner ... has exceeded the maximum execution time` | Timeout | Job timeout (default 360min) | Add `timeout-minutes` or optimize job | +| `Error: buildx failed with: ERROR: failed to solve` | Docker | Build context or Dockerfile error | Check COPY paths, multi-stage build, layer caching | +| `YAML syntax error` | Syntax | Invalid YAML | Validate with `yamllint`, check indentation (use spaces, not tabs) | +| `Invalid workflow file: .github/workflows/X.yml#L10` | Syntax | Schema validation failed | Check action inputs, required fields, job dependencies | +| `Error: Unable to locate executable file: X` | Environment | Tool not installed on runner | Add setup action (setup-node, setup-python) or install in job | +| `ENOENT: no such file or directory` | Environment | Missing file/directory | Check working-directory, ensure previous steps succeeded | +| `fatal: not a git repository` | Environment | Working directory incorrect | Use `actions/checkout` before commands | +| `Error: No such container: X` | Environment | Docker service not started | Add service container or start docker daemon | +| `error: failed to push some refs` | Git | Conflict or protection | Pull latest changes, resolve conflicts, check branch protection | +| `Error: HttpError: Resource protected by organization SAML enforcement` | Permission | SAML SSO not authorized | Authorize token for SAML SSO in org settings | +| `error: RPC failed; HTTP 400` | Network | Large push or network issue | Increase git buffer: `git config http.postBuffer 524288000` | +| `curl: (6) Could not resolve host` | Network | DNS or network failure | Retry with backoff or check runner network config | + +--- + +## Tool Selection Guidance + +Choose the right tool for efficient debugging: + +### Use `read` when: +- Reading workflow files (<500 lines) +- Checking action definitions +- Reviewing configuration files (package.json, Dockerfile) + +### Use `grep` when: +- Searching for specific error patterns across multiple files +- Finding all occurrences of a keyword +- Locating action usage in workflows + +### Use `pty_read` with pattern filtering when: +- Analyzing large log files (>500 lines) +- Extracting errors from verbose output +- Filtering for specific error types + +### Use `bash` when: +- Validating YAML syntax (yamllint) +- Checking file existence/permissions +- Running git commands for context + +### Use `scripts/parse_workflow_logs.py` when: +- Log file >500 lines with multiple errors +- Need structured JSON output for complex analysis +- Batch processing multiple error types + +--- + +## Output Format Requirements + +### For Single Error: +```markdown +## Workflow Failure Analysis + +**Failed Job:** [job-name] +**Failed Step:** [step-name] +**Runner:** [ubuntu-latest/etc] + +### Error +``` +[Exact error message with context] +``` + +### Root Cause +[Specific cause with evidence from logs/config] + +### Fix +```yaml +# .github/workflows/ci.yml +[Exact code changes] +``` + +### Explanation +[Why this resolves the issue] + +### Prevention +[How to avoid this in future] +``` + +### For Multiple Errors: +Provide summary table, then detailed analysis for each: + +```markdown +## Workflow Failure Summary + +| Error # | Category | Severity | Root Cause | +|---------|----------|----------|------------| +| 1 | Dependency | Critical | npm peer dependency conflict | +| 2 | Timeout | Warning | Test suite slow | + +--- + +## Error 1: Dependency Conflict +[Detailed analysis...] + +## Error 2: Test Timeout +[Detailed analysis...] +``` + +--- + +## Integration with Existing Skills/Agents + +### Delegate to `github-pr` skill when: +- Failure is related to PR workflow (reviews, status checks) +- Need to analyze PR comments or review feedback +- CI check failure is part of broader PR debugging + +### Delegate to `github-debugger` agent when: +- Issue requires specialized debugging beyond workflow logs +- Need to trace application-level errors vs. CI/CD errors +- Complex multi-repo debugging scenario + +### Stay in `github-actions-debugging` when: +- Error is clearly workflow configuration or GHA platform issue +- Log analysis and pattern matching can resolve issue +- Solution involves modifying workflow files or action configuration + +--- + +## Edge Cases and Special Scenarios + +### Matrix Builds with Partial Failures +- Identify which matrix combinations failed +- Look for environment-specific issues (OS, version) +- Provide fixes that target specific matrix cells + +### Forked PR Workflow Failures +- Check if failure is due to secret access restrictions +- Verify if `pull_request_target` is needed +- Assess security implications of proposed fixes + +### Intermittent Failures +- Look for race conditions, timing dependencies +- Check for flaky tests vs. infrastructure issues +- Recommend retry strategies or test isolation + +### Composite Action Errors +- Trace error to specific action step +- Check action.yml definition +- Verify input/output mappings + +### Reusable Workflow Failures +- Distinguish caller vs. called workflow errors +- Check input passing and secret inheritance +- Verify workflow_call trigger configuration + +--- + +## Performance Optimization + +**Token Efficiency:** +- Load `error-patterns.md` only when Quick Reference table insufficient +- Load `examples.md` only for complex multi-error scenarios +- Use script for large logs instead of reading full output + +**Time Efficiency:** +- Start with most recent logs (use offset in pty_read) +- Search for error keywords before reading full context +- Batch grep operations for multiple patterns + +--- + +## Additional Resources + +When core instructions are insufficient, load these files: + +- **`error-patterns.md`**: Comprehensive database of 100+ error patterns with detailed fixes +- **`examples.md`**: Step-by-step walkthroughs of complex debugging scenarios +- **`scripts/parse_workflow_logs.py`**: Automated log parser for large files +- **`resources/error-patterns.json`**: Machine-readable pattern database + +Load resources only when needed to maintain token efficiency. diff --git a/.claude/skills/github-actions-debugging/error-patterns.md b/.claude/skills/github-actions-debugging/error-patterns.md new file mode 100644 index 0000000..05390bf --- /dev/null +++ b/.claude/skills/github-actions-debugging/error-patterns.md @@ -0,0 +1,760 @@ +# Comprehensive GitHub Actions Error Patterns + +This file provides detailed error patterns, root causes, and solutions for GitHub Actions debugging. Load this when the Quick Reference table in SKILL.md is insufficient. + +--- + +## Syntax & Configuration Errors + +### YAML Syntax Errors + +**Error Signature:** +``` +Error: .github/workflows/ci.yml (Line: X, Col: Y): Unexpected token +YAML syntax error +Invalid workflow file +``` + +**Root Causes:** +- Incorrect indentation (mixing tabs and spaces) +- Missing quotes around special characters +- Invalid character in key names +- Unclosed brackets/braces +- Missing colons after keys + +**Fixes:** +1. Run `yamllint .github/workflows/` to identify syntax issues +2. Use 2-space indentation consistently (no tabs) +3. Quote strings containing `:`, `{`, `}`, `[`, `]`, `,`, `&`, `*`, `#`, `?`, `|`, `-`, `<`, `>`, `=`, `!`, `%`, `@`, `` ` `` +4. Validate online: https://www.yamllint.com/ + +**Prevention:** +- Use editor with YAML syntax highlighting +- Install yamllint pre-commit hook +- Use GitHub Actions extension in VS Code + +--- + +### Invalid Workflow Schema + +**Error Signature:** +``` +Invalid workflow file: .github/workflows/X.yml#L10 +The workflow is not valid. .github/workflows/X.yml (Line: 10, Col: 3): Unexpected value 'X' +``` + +**Root Causes:** +- Missing required fields (name, on, jobs) +- Invalid action input names +- Incorrect job dependency in `needs` +- Invalid trigger event names +- Wrong context variable syntax + +**Fixes:** +1. Verify required top-level keys exist: + ```yaml + name: My Workflow + on: [push] + jobs: + build: + runs-on: ubuntu-latest + steps: [] + ``` + +2. Check action inputs match action.yml definition +3. Validate `needs` references existing job names +4. Use correct trigger events: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows +5. Use `${{ }}` syntax for expressions + +**Prevention:** +- Use schema validation in editor +- Reference official docs for each action +- Test workflows in forked repos first + +--- + +## Dependency Errors + +### npm - Peer Dependency Conflicts + +**Error Signature:** +``` +npm ERR! code ERESOLVE +npm ERR! ERESOLVE unable to resolve dependency tree +npm ERR! Could not resolve dependency: +npm ERR! peer X@"Y" from Z@A +``` + +**Root Causes:** +- Package requires incompatible peer dependency versions +- Lock file generated with different npm version +- Transitive dependency conflicts +- Strict peer dependency resolution in npm 7+ + +**Fixes:** +1. **Quick fix** (not recommended for production): + ```yaml + - run: npm install --legacy-peer-deps + ``` + +2. **Proper fix**: + ```yaml + - run: npm install --force + # or + - run: | + npm config set legacy-peer-deps true + npm ci + ``` + +3. **Best fix** - Update package.json: + ```json + { + "overrides": { + "problematic-package": "compatible-version" + } + } + ``` + +**Prevention:** +- Pin npm version in workflow +- Commit package-lock.json +- Use `npm ci` instead of `npm install` +- Keep dependencies updated regularly + +--- + +### npm - Lock File Out of Sync + +**Error Signature:** +``` +npm ERR! code EUSAGE +npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync +npm ERR! Please update your lock file with `npm install` before continuing. +``` + +**Root Causes:** +- package.json modified without updating lock file +- Lock file generated with different npm version +- Manual lock file edits +- Merge conflict resolution errors + +**Fixes:** +1. Regenerate lock file: + ```bash + rm package-lock.json + npm install + git add package-lock.json + git commit -m "fix: regenerate lock file" + ``` + +2. Update workflow to use npm install: + ```yaml + - run: npm install + # Instead of npm ci temporarily + ``` + +**Prevention:** +- Always run `npm install` after changing package.json +- Commit lock file changes with dependency changes +- Use `npm ci` in CI/CD (enforces sync check) +- Pin npm version in workflow + +--- + +### Python - pip Dependency Resolution + +**Error Signature:** +``` +ERROR: Cannot install X because these package versions have incompatible dependencies. +ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution +``` + +**Root Causes:** +- Conflicting version requirements +- Package not available for Python version +- Platform-specific dependency issues +- pip resolver cannot find compatible versions + +**Fixes:** +1. Pin conflicting packages explicitly: + ```txt + # requirements.txt + package-a==1.2.3 + package-b==4.5.6 # Compatible with package-a + ``` + +2. Use constraint files: + ```yaml + - run: pip install -r requirements.txt -c constraints.txt + ``` + +3. Upgrade pip resolver: + ```yaml + - run: python -m pip install --upgrade pip setuptools wheel + ``` + +4. Use virtual environment isolation: + ```yaml + - run: | + python -m venv venv + source venv/bin/activate + pip install -r requirements.txt + ``` + +**Prevention:** +- Use requirements.txt with pinned versions +- Test with same Python version locally +- Use dependency management tools (poetry, pipenv) +- Commit lock files (poetry.lock, Pipfile.lock) + +--- + +### Go - Module Inconsistencies + +**Error Signature:** +``` +go: inconsistent vendoring in /home/runner/work/repo/repo: +go: inconsistent vendoring +``` + +**Root Causes:** +- go.mod and vendor/ out of sync +- Missing vendor directory +- go.sum verification failure +- Dependency version mismatch + +**Fixes:** +1. Regenerate vendor directory: + ```yaml + - run: | + go mod tidy + go mod vendor + ``` + +2. Update go.sum: + ```yaml + - run: go mod download + ``` + +3. Disable vendoring: + ```yaml + - run: go build -mod=mod ./... + ``` + +**Prevention:** +- Commit vendor/ directory or exclude it consistently +- Run `go mod tidy` before committing +- Use same Go version locally and in CI +- Enable Go modules checksum database + +--- + +## Permission Errors + +### Token Insufficient Permissions + +**Error Signature:** +``` +Error: Resource not accessible by integration +Error: HttpError: Resource not accessible by integration +``` + +**Root Causes:** +- GITHUB_TOKEN lacks required permissions +- Default token permissions too restrictive +- Organization security policy restrictions +- Token not passed to composite action + +**Fixes:** +1. Add permissions to workflow: + ```yaml + permissions: + contents: write + pull-requests: write + issues: write + ``` + +2. Add permissions to specific job: + ```yaml + jobs: + deploy: + permissions: + contents: write + runs-on: ubuntu-latest + ``` + +3. Use PAT instead of GITHUB_TOKEN: + ```yaml + - uses: actions/checkout@v3 + with: + token: ${{ secrets.PAT_TOKEN }} + ``` + +**Prevention:** +- Use least-privilege principle +- Document required permissions in README +- Test with default token permissions first +- Check org settings for token restrictions + +--- + +### SSH Authentication Failures + +**Error Signature:** +``` +Permission denied (publickey) +fatal: Could not read from remote repository +Host key verification failed +``` + +**Root Causes:** +- SSH key not configured in repository +- Wrong SSH key used +- Host key verification failure +- SSH agent not running + +**Fixes:** +1. Use HTTPS with token instead: + ```yaml + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ``` + +2. Configure SSH key: + ```yaml + - uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + ``` + +3. Disable host key checking (not recommended): + ```yaml + - run: | + mkdir -p ~/.ssh + echo "StrictHostKeyChecking no" >> ~/.ssh/config + ``` + +**Prevention:** +- Prefer HTTPS over SSH in CI/CD +- Use deploy keys for repository access +- Document SSH key setup requirements +- Rotate SSH keys regularly + +--- + +### SAML SSO Authorization + +**Error Signature:** +``` +Error: HttpError: Resource protected by organization SAML enforcement +``` + +**Root Causes:** +- Personal access token not authorized for SAML SSO +- Token created before SAML enforcement +- Organization security policy change + +**Fixes:** +1. Authorize token for SSO: + - Go to GitHub Settings → Developer settings → Personal access tokens + - Find the token + - Click "Configure SSO" → "Authorize" for organization + +2. Create new token with SSO authorization: + ```yaml + # Use newly created and authorized token + - uses: actions/checkout@v3 + with: + token: ${{ secrets.SAML_AUTHORIZED_TOKEN }} + ``` + +**Prevention:** +- Authorize tokens for SSO immediately after creation +- Document SSO requirement in workflow README +- Use GitHub Apps instead of PATs when possible +- Audit token authorizations regularly + +--- + +## Timeout & Resource Errors + +### Job Timeout + +**Error Signature:** +``` +##[error]The job running on runner X has exceeded the maximum execution time of 360 minutes. +Error: The operation was canceled. +``` + +**Root Causes:** +- Long-running tests or builds +- Infinite loops or hangs +- Waiting for external service +- Default timeout too short for job + +**Fixes:** +1. Increase job timeout: + ```yaml + jobs: + build: + timeout-minutes: 120 # Default is 360 + runs-on: ubuntu-latest + ``` + +2. Increase step timeout: + ```yaml + - name: Run tests + timeout-minutes: 30 + run: npm test + ``` + +3. Optimize slow operations: + - Use caching for dependencies + - Parallelize tests + - Split into multiple jobs + - Use faster runners + +**Prevention:** +- Set appropriate timeouts for each job +- Monitor job duration trends +- Optimize test suite performance +- Use matrix builds for parallelization + +--- + +### Out of Memory (OOM) + +**Error Signature:** +``` +##[error]Process completed with exit code 137 +Killed +npm ERR! errno 137 +``` + +**Root Causes:** +- Process exceeded available memory (7GB on standard runners) +- Memory leak in tests or build +- Large file processing +- Too many parallel processes + +**Fixes:** +1. Increase Node.js memory: + ```yaml + - run: export NODE_OPTIONS="--max-old-space-size=6144" + - run: npm run build + ``` + +2. Reduce parallelism: + ```yaml + - run: npm test -- --maxWorkers=2 + ``` + +3. Use larger runner: + ```yaml + jobs: + build: + runs-on: ubuntu-latest-8-cores # Requires GitHub Team/Enterprise + ``` + +4. Split job into smaller pieces: + ```yaml + strategy: + matrix: + shard: [1, 2, 3, 4] + steps: + - run: npm test -- --shard=${{ matrix.shard }}/4 + ``` + +**Prevention:** +- Monitor memory usage in CI +- Fix memory leaks in code +- Use streaming for large files +- Optimize build configuration + +--- + +## Environment Errors + +### Missing Tool or Command + +**Error Signature:** +``` +Error: Unable to locate executable file: X +/bin/bash: X: command not found +``` + +**Root Causes:** +- Tool not pre-installed on runner +- Wrong runner image +- PATH not configured +- Tool installation failed + +**Fixes:** +1. Use setup action: + ```yaml + - uses: actions/setup-node@v3 + with: + node-version: '18' + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + ``` + +2. Install tool manually: + ```yaml + - run: | + sudo apt-get update + sudo apt-get install -y tool-name + ``` + +3. Use container with tool pre-installed: + ```yaml + jobs: + build: + runs-on: ubuntu-latest + container: node:18-alpine + ``` + +**Prevention:** +- Check runner software: https://github.com/actions/runner-images +- Use setup actions for language runtimes +- Document custom tool requirements +- Use containers for complex environments + +--- + +### Missing Files or Directories + +**Error Signature:** +``` +ENOENT: no such file or directory, open 'X' +Error: File not found: X +``` + +**Root Causes:** +- File not checked out +- Wrong working directory +- Previous step failed silently +- File path case sensitivity (Linux vs. Windows) + +**Fixes:** +1. Ensure checkout step exists: + ```yaml + - uses: actions/checkout@v3 + ``` + +2. Set correct working directory: + ```yaml + - run: npm install + working-directory: ./frontend + ``` + +3. Check file exists before using: + ```yaml + - run: | + if [ ! -f "config.json" ]; then + echo "config.json not found" + exit 1 + fi + ``` + +**Prevention:** +- Always use actions/checkout first +- Use relative paths from repository root +- Add file existence checks +- Test on same OS as runner + +--- + +## Network & External Service Errors + +### DNS Resolution Failures + +**Error Signature:** +``` +curl: (6) Could not resolve host: example.com +getaddrinfo ENOTFOUND example.com +``` + +**Root Causes:** +- Temporary DNS issue +- Service outage +- Network connectivity problem +- Firewall blocking DNS + +**Fixes:** +1. Add retry logic: + ```yaml + - uses: nick-fields/retry@v2 + with: + timeout_minutes: 10 + max_attempts: 3 + command: curl https://example.com + ``` + +2. Use alternative DNS: + ```yaml + - run: | + echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf + ``` + +3. Check service status before proceeding: + ```yaml + - run: | + until curl -f https://api.example.com/health; do + echo "Waiting for service..." + sleep 5 + done + ``` + +**Prevention:** +- Implement retry mechanisms +- Monitor external service dependencies +- Use health checks before integration tests +- Have fallback strategies + +--- + +### Rate Limiting + +**Error Signature:** +``` +Error: API rate limit exceeded +Error: You have exceeded a secondary rate limit +403 Forbidden +``` + +**Root Causes:** +- Too many API requests in short time +- Shared runner IP rate limited +- GitHub API secondary rate limits +- Missing authentication + +**Fixes:** +1. Add authentication: + ```yaml + - run: | + curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/owner/repo + ``` + +2. Add delays between requests: + ```yaml + - run: | + for repo in $REPOS; do + gh api repos/$repo + sleep 2 + done + ``` + +3. Use GraphQL instead of REST (fewer requests): + ```yaml + - run: | + gh api graphql -f query='...' + ``` + +**Prevention:** +- Authenticate all API requests +- Cache API responses +- Batch operations when possible +- Monitor rate limit headers + +--- + +## Docker & Container Errors + +### Docker Build Failures + +**Error Signature:** +``` +Error: buildx failed with: ERROR: failed to solve +ERROR [internal] load metadata for docker.io/library/X +COPY failed: file not found +``` + +**Root Causes:** +- Invalid base image or tag +- File path incorrect in COPY/ADD +- Build context doesn't include files +- Multi-stage build reference error + +**Fixes:** +1. Verify base image exists: + ```dockerfile + FROM node:18-alpine # Use specific tag + ``` + +2. Fix COPY paths: + ```dockerfile + # Ensure files are in build context + COPY package*.json ./ + COPY . . + ``` + +3. Set correct build context: + ```yaml + - run: docker build -t myapp:latest . + # Context is current directory + ``` + +4. Debug build context: + ```yaml + - run: docker build --progress=plain --no-cache -t myapp . + ``` + +**Prevention:** +- Use specific image tags (not :latest) +- Test Dockerfile locally first +- Use .dockerignore to exclude files +- Validate multi-stage build references + +--- + +## Matrix Build Errors + +### Partial Matrix Failures + +**Error Signature:** +``` +Some jobs in the matrix failed +Error in matrix combination: os=windows-latest, node=14 +``` + +**Root Causes:** +- Platform-specific bugs +- Version incompatibilities +- Different default tools per OS +- Path separator differences + +**Fixes:** +1. Add conditional steps: + ```yaml + - name: Windows-specific setup + if: runner.os == 'Windows' + run: | + # Windows commands + ``` + +2. Use cross-platform commands: + ```yaml + - run: npm ci # Works on all platforms + # Instead of platform-specific commands + ``` + +3. Exclude failing combinations: + ```yaml + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: [14, 16, 18] + exclude: + - os: windows-latest + node: 14 + ``` + +**Prevention:** +- Test locally on target platforms +- Use cross-platform tools +- Document platform-specific requirements +- Use continue-on-error for non-critical combinations diff --git a/.claude/skills/github-actions-debugging/examples.md b/.claude/skills/github-actions-debugging/examples.md new file mode 100644 index 0000000..652225b --- /dev/null +++ b/.claude/skills/github-actions-debugging/examples.md @@ -0,0 +1,644 @@ +# GitHub Actions Debugging Examples + +Step-by-step walkthroughs of complex debugging scenarios. Load this file when you need concrete examples of the debugging methodology in action. + +--- + +## Example 1: npm Dependency Resolution Failure + +### Initial Failure +``` +Run npm ci +npm ERR! code ERESOLVE +npm ERR! ERESOLVE unable to resolve dependency tree +npm ERR! +npm ERR! While resolving: my-app@1.0.0 +npm ERR! Found: react@17.0.2 +npm ERR! node_modules/react +npm ERR! react@"^17.0.2" from the root project +npm ERR! +npm ERR! Could not resolve dependency: +npm ERR! peer react@"^18.0.0" from react-awesome-library@2.0.0 +npm ERR! node_modules/react-awesome-library +npm ERR! react-awesome-library@"^2.0.0" from the root project +Error: Process completed with exit code 1. +``` + +### Phase 1: Context Gathering +- **Failed Job:** `build` +- **Failed Step:** `Install dependencies` +- **Runner:** `ubuntu-latest` +- **Trigger:** PR merge to main + +### Phase 2: Log Analysis +Error indicates peer dependency conflict: +- Current project uses React 17 +- New dependency requires React 18 +- npm 7+ enforces strict peer dependencies + +### Phase 3: Error Categorization +- **Category:** Dependency +- **Severity:** Critical (blocks build) +- **Type:** Deterministic + +### Phase 4: Root Cause Analysis +Recent changes show `react-awesome-library` was added in package.json but React version wasn't updated: + +```json +{ + "dependencies": { + "react": "^17.0.2", + "react-awesome-library": "^2.0.0" // Requires React 18 + } +} +``` + +### Phase 5: Solution + +**Root Cause:** +Added dependency requires React 18, but project still on React 17. + +**Fix:** +```json +{ + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-awesome-library": "^2.0.0" + } +} +``` + +Then regenerate lock file: +```bash +rm package-lock.json +npm install +git add package.json package-lock.json +git commit -m "fix: upgrade React to v18 for react-awesome-library compatibility" +``` + +**Why This Works:** +Upgrades React to version compatible with all dependencies, satisfying peer dependency requirements. + +**Prevention:** +- Check peer dependencies before adding packages +- Use `npm info package peerDependencies` to verify +- Keep major dependencies up to date + +**Verification:** +```bash +npm ci # Should succeed locally +# Push and verify CI passes +``` + +--- + +## Example 2: Permission Denied Pushing Docker Image + +### Initial Failure +``` +Run docker push ghcr.io/org/myapp:latest +denied: permission_denied: write_package +Error: Process completed with exit code 1. +``` + +### Phase 1: Context Gathering +- **Failed Job:** `deploy` +- **Failed Step:** `Push Docker image` +- **Runner:** `ubuntu-latest` +- **Trigger:** Push to main branch + +### Phase 2: Log Analysis +Error shows permission denied when pushing to GitHub Container Registry (ghcr.io). + +### Phase 3: Error Categorization +- **Category:** Permission +- **Severity:** Critical +- **Type:** Deterministic + +### Phase 4: Root Cause Analysis +Workflow file shows: +```yaml +- name: Push Docker image + run: docker push ghcr.io/org/myapp:latest +``` + +GITHUB_TOKEN default permissions don't include package write access. + +### Phase 5: Solution + +**Root Cause:** +GITHUB_TOKEN lacks `packages: write` permission needed for pushing to GitHub Container Registry. + +**Fix:** +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # Add this + steps: + - uses: actions/checkout@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + push: true + tags: ghcr.io/org/myapp:latest +``` + +**Why This Works:** +- Adds `packages: write` permission to job +- Uses proper login action with GITHUB_TOKEN +- Authenticates before pushing + +**Prevention:** +- Always add permissions explicitly for package operations +- Use docker/login-action for authentication +- Document required permissions in workflow comments + +**Verification:** +```bash +# Check image was pushed +gh api /user/packages/container/myapp/versions +``` + +--- + +## Example 3: Test Suite Timeout on Large Codebase + +### Initial Failure +``` +Run npm test +PASS src/components/Button.test.tsx +PASS src/components/Input.test.tsx +... +(2000+ test files) +... +##[error]The job running on runner GitHub Actions 2 has exceeded the maximum execution time of 360 minutes. +Error: The operation was canceled. +``` + +### Phase 1: Context Gathering +- **Failed Job:** `test` +- **Failed Step:** `Run tests` +- **Runner:** `ubuntu-latest` +- **Trigger:** PR +- **Context:** Large monorepo with 2000+ test files + +### Phase 2: Log Analysis +Job timed out after 360 minutes (6 hours) while running Jest tests sequentially. + +### Phase 3: Error Categorization +- **Category:** Timeout +- **Severity:** Critical +- **Type:** Deterministic (always fails) + +### Phase 4: Root Cause Analysis +Workflow runs all tests sequentially: +```yaml +- run: npm test +``` + +No parallelization or caching. Tests run on single worker. + +### Phase 5: Solution + +**Root Cause:** +Running 2000+ test files sequentially on single worker exceeds job timeout. + +**Fix - Use Matrix Strategy with Sharding:** +```yaml +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8] + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - run: npm ci + + - name: Run tests (shard ${{ matrix.shard }}/8) + run: npm test -- --shard=${{ matrix.shard }}/8 --maxWorkers=2 + timeout-minutes: 45 +``` + +**Why This Works:** +- Splits tests into 8 parallel jobs (shards) +- Each shard runs ~250 test files +- Reduces total time from 360+ min to ~45 min per shard +- Uses npm cache to speed up dependency installation +- Sets per-step timeout to fail fast + +**Prevention:** +- Use test sharding for large test suites +- Monitor test execution time trends +- Optimize slow tests +- Use cached dependencies + +**Verification:** +- Each shard should complete in <45 minutes +- Total wall-clock time: ~45 minutes (parallel) +- All 8 shards must pass for PR to merge + +--- + +## Example 4: Matrix Build Partial Failure (Windows-Specific) + +### Initial Failure +``` +Matrix: os=windows-latest, node=18 +Run npm run build +> my-app@1.0.0 build +> webpack --mode production + +Error: EPERM: operation not permitted, rename 'dist\bundle.js.tmp' -> 'dist\bundle.js' +``` + +All other matrix combinations (Ubuntu, macOS) passed. + +### Phase 1: Context Gathering +- **Failed Job:** `build` +- **Matrix:** `os=windows-latest, node=18` +- **Other Combinations:** All passed (Ubuntu, macOS) +- **Trigger:** PR + +### Phase 2: Log Analysis +Windows-specific EPERM error when webpack tries to rename temp file. This is a known Windows file locking issue. + +### Phase 3: Error Categorization +- **Category:** Environment (OS-specific) +- **Severity:** Critical (blocks Windows builds) +- **Type:** Intermittent (Windows file locking race condition) + +### Phase 4: Root Cause Analysis +Windows file system locks files more aggressively than Unix systems. Webpack's file writing can trigger EPERM errors when: +- Antivirus scans lock files +- File handles not released immediately +- Temp file cleanup race condition + +### Phase 5: Solution + +**Root Cause:** +Windows file system locking causes webpack file rename failures during parallel builds. + +**Fix - Add Retry Logic and Reduce Parallelism:** +```yaml +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: [16, 18, 20] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + + - run: npm ci + + - name: Build (Windows) + if: runner.os == 'Windows' + uses: nick-fields/retry@v2 + with: + timeout_minutes: 10 + max_attempts: 3 + command: npm run build + env: + # Reduce webpack parallelism on Windows + NODE_OPTIONS: --max-old-space-size=4096 + + - name: Build (Unix) + if: runner.os != 'Windows' + run: npm run build +``` + +**Alternative Fix - Adjust webpack config:** +```javascript +// webpack.config.js +module.exports = { + // Disable webpack's caching on Windows + cache: process.platform === 'win32' ? false : { + type: 'filesystem', + }, + // Reduce parallelism on Windows + parallelism: process.platform === 'win32' ? 1 : 100, +}; +``` + +**Why This Works:** +- Retry logic handles intermittent file locking +- Reduced parallelism minimizes concurrent file operations +- Windows-specific configuration prevents race conditions + +**Prevention:** +- Test builds on Windows locally +- Use platform-specific configurations +- Monitor for Windows-specific issues +- Consider excluding problematic matrix combinations if not critical + +**Verification:** +Re-run workflow multiple times to verify Windows builds succeed consistently. + +--- + +## Example 5: Secrets Not Available in Forked PR + +### Initial Failure +``` +Run aws s3 cp dist/ s3://my-bucket --recursive +fatal error: Unable to locate credentials +Error: Process completed with exit code 1. +``` + +Works on direct PRs, fails on forked PRs. + +### Phase 1: Context Gathering +- **Failed Job:** `deploy-preview` +- **Trigger:** PR from forked repository +- **Context:** Workflow tries to deploy to S3 using secrets + +### Phase 2: Log Analysis +AWS credentials not found. Secrets are not available to forked PRs for security reasons. + +### Phase 3: Error Categorization +- **Category:** Permission (secrets unavailable) +- **Severity:** Expected behavior (security feature) +- **Type:** Deterministic for forks + +### Phase 4: Root Cause Analysis +GitHub Actions doesn't expose secrets to workflows triggered by forked PRs to prevent secret exfiltration. Current workflow: +```yaml +on: [pull_request] + +jobs: + deploy-preview: + runs-on: ubuntu-latest + steps: + - run: aws s3 cp dist/ s3://my-bucket --recursive + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +``` + +### Phase 5: Solution + +**Root Cause:** +Secrets are not available to workflows triggered by forked PRs for security reasons. + +**Fix - Skip deployment for forks:** +```yaml +on: [pull_request] + +jobs: + deploy-preview: + runs-on: ubuntu-latest + # Only run for PRs from same repo + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v3 + + - run: npm run build + + - name: Deploy to S3 + run: aws s3 cp dist/ s3://my-bucket/pr-${{ github.event.number }}/ --recursive + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +``` + +**Alternative - Use pull_request_target (careful!):** +```yaml +# WARNING: Only use if you understand security implications +on: + pull_request_target: # Has access to secrets + +jobs: + deploy-preview: + runs-on: ubuntu-latest + steps: + # CRITICAL: Check out PR code in isolated step + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + + # Build in isolated environment (no secrets) + - run: npm ci + - run: npm run build + + # Only expose secrets to trusted deployment step + - name: Deploy + run: aws s3 cp dist/ s3://my-bucket --recursive + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +``` + +**Why This Works:** +- First approach skips deployment for forked PRs (safe) +- Second approach uses `pull_request_target` which has secret access but requires careful security review + +**Prevention:** +- Document fork PR limitations +- Use conditions to skip secret-dependent steps for forks +- Consider separate workflow for fork PRs (build only) +- Use pull_request_target only when necessary and with security review + +**Verification:** +- Test with fork PR (should skip deployment or build only) +- Test with same-repo PR (should deploy) + +--- + +## Example 6: Cache Restoration Failure After Dependency Update + +### Initial Failure +``` +Run actions/cache@v3 +Cache not found for input keys: node-modules-${{ hashFiles('**/package-lock.json') }} +... +Run npm ci +(Takes 5+ minutes instead of usual 30 seconds) +``` + +Build succeeds but much slower than usual. + +### Phase 1: Context Gathering +- **Failed Step:** `Restore cache` +- **Impact:** Build time increased from 2min to 7min +- **Trigger:** PR updating dependencies +- **Context:** package-lock.json was modified + +### Phase 2: Log Analysis +Cache key uses hash of package-lock.json. After dependency update, hash changed, invalidating cache. + +### Phase 3: Error Categorization +- **Category:** Performance (not a failure, but degraded) +- **Severity:** Warning +- **Type:** Expected behavior after dependency changes + +### Phase 4: Root Cause Analysis +Workflow uses exact cache key: +```yaml +- uses: actions/cache@v3 + with: + path: ~/.npm + key: node-modules-${{ hashFiles('**/package-lock.json') }} +``` + +No restore-keys specified, so when package-lock.json changes, cache completely missed. + +### Phase 5: Solution + +**Root Cause:** +Cache key based on package-lock.json hash invalidates completely on dependency updates. No fallback strategy. + +**Fix - Add restore-keys for partial matches:** +```yaml +- uses: actions/cache@v3 + with: + path: ~/.npm + key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + node-modules-${{ runner.os }}- +``` + +**Better - Use actions/setup-node built-in caching:** +```yaml +- uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' # Automatically handles caching +``` + +**Why This Works:** +- `restore-keys` allows partial cache hits when exact key misses +- Restores most recent cache even if package-lock.json changed +- npm ci only reinstalls changed packages +- setup-node's cache handles this automatically + +**Prevention:** +- Always use restore-keys with cache +- Use built-in caching features when available +- Monitor cache hit rates +- Expect cache misses after dependency updates (normal) + +**Verification:** +- First run after change: cache miss (expected) +- Subsequent runs: cache hit +- Build time returns to normal + +--- + +## Example 7: Composite Action Input Validation Failure + +### Initial Failure +``` +Run ./.github/actions/deploy +Error: Input required and not supplied: environment +Error: Required input 'environment' not provided +``` + +### Phase 1: Context Gathering +- **Failed Step:** Custom composite action +- **Action:** `./.github/actions/deploy` +- **Trigger:** Workflow using composite action + +### Phase 2: Log Analysis +Composite action expects `environment` input but workflow didn't provide it. + +Action definition (action.yml): +```yaml +name: Deploy +inputs: + environment: + required: true + description: 'Deployment environment' +runs: + using: composite + steps: + - run: echo "Deploying to ${{ inputs.environment }}" +``` + +Workflow usage: +```yaml +- uses: ./.github/actions/deploy + # Missing: with.environment +``` + +### Phase 3: Error Categorization +- **Category:** Syntax/Configuration +- **Severity:** Critical +- **Type:** Deterministic + +### Phase 4: Root Cause Analysis +Workflow author didn't provide required input when calling composite action. + +### Phase 5: Solution + +**Root Cause:** +Required input `environment` not provided to composite action. + +**Fix - Provide required input:** +```yaml +- uses: ./.github/actions/deploy + with: + environment: production +``` + +**Better - Make input optional with default:** +```yaml +# .github/actions/deploy/action.yml +inputs: + environment: + required: false + default: 'staging' + description: 'Deployment environment' +``` + +**Why This Works:** +- Provides required input to action +- Or makes input optional with sensible default + +**Prevention:** +- Document required inputs in action README +- Use input validation in composite actions +- Provide helpful error messages +- Consider defaults for optional inputs + +**Verification:** +```bash +# Test composite action locally +act -j deploy +``` + +--- + +## Summary + +These examples demonstrate: +- **Systematic approach** to debugging across error categories +- **Root cause analysis** beyond surface-level symptoms +- **Multiple solution strategies** with tradeoffs +- **Prevention measures** to avoid recurring issues +- **Verification steps** to confirm fixes work + +Apply the same 5-phase methodology to any GitHub Actions failure for consistent, efficient debugging. diff --git a/.claude/skills/github-actions-debugging/resources/error-patterns.json b/.claude/skills/github-actions-debugging/resources/error-patterns.json new file mode 100644 index 0000000..6c955f6 --- /dev/null +++ b/.claude/skills/github-actions-debugging/resources/error-patterns.json @@ -0,0 +1,215 @@ +[ + { + "pattern": "npm ERR! code ERESOLVE", + "category": "dependency", + "severity": "error", + "causes": [ + "Conflicting peer dependencies", + "npm 7+ strict peer dependency resolution", + "Outdated lock file" + ], + "fixes": [ + "Run npm install --legacy-peer-deps", + "Update conflicting packages to compatible versions", + "Delete package-lock.json and regenerate with npm install" + ], + "prevention": "Pin dependency versions and test with npm 7+ locally" + }, + { + "pattern": "npm ERR!.*EUSAGE.*package.json and package-lock.json.*in sync", + "category": "dependency", + "severity": "error", + "causes": [ + "package.json modified without updating lock file", + "Lock file generated with different npm version", + "Merge conflict resolution errors" + ], + "fixes": [ + "Delete package-lock.json and run npm install", + "Run npm install after changing package.json", + "Commit lock file changes with dependency changes" + ], + "prevention": "Always run npm install after changing package.json" + }, + { + "pattern": "pip.*error.*ResolutionImpossible", + "category": "dependency", + "severity": "error", + "causes": [ + "Conflicting version requirements", + "Package not available for Python version", + "Platform-specific dependency issues" + ], + "fixes": [ + "Pin conflicting packages explicitly in requirements.txt", + "Use constraint files with pip install -c constraints.txt", + "Upgrade pip resolver: python -m pip install --upgrade pip" + ], + "prevention": "Use requirements.txt with pinned versions" + }, + { + "pattern": "Resource not accessible by integration", + "category": "permission", + "severity": "error", + "causes": [ + "GITHUB_TOKEN lacks required permissions", + "Default token permissions too restrictive", + "Organization security policy restrictions" + ], + "fixes": [ + "Add permissions block to workflow with required scopes", + "Use PAT instead of GITHUB_TOKEN", + "Check organization security settings" + ], + "prevention": "Always specify permissions explicitly in workflows" + }, + { + "pattern": "Permission denied \\(publickey\\)", + "category": "permission", + "severity": "error", + "causes": [ + "SSH key not configured", + "Wrong SSH key used", + "Host key verification failure" + ], + "fixes": [ + "Use HTTPS with token instead of SSH", + "Configure SSH key using webfactory/ssh-agent action", + "Add deploy key to repository settings" + ], + "prevention": "Prefer HTTPS over SSH in CI/CD workflows" + }, + { + "pattern": "##\\[error\\].*exceeded the maximum execution time", + "category": "timeout", + "severity": "error", + "causes": [ + "Job exceeds default 360 minute timeout", + "Long-running tests or builds", + "Infinite loops or hangs" + ], + "fixes": [ + "Add timeout-minutes to job or step", + "Optimize slow operations using caching", + "Use matrix strategy for parallelization", + "Split into multiple smaller jobs" + ], + "prevention": "Set appropriate timeouts and monitor job duration" + }, + { + "pattern": "exit code 137", + "category": "timeout", + "severity": "error", + "causes": [ + "Process killed by OOM (out of memory)", + "Exceeded 7GB memory limit on standard runners", + "Memory leak in tests or build" + ], + "fixes": [ + "Increase Node.js memory: NODE_OPTIONS=--max-old-space-size=6144", + "Reduce parallelism in tests: --maxWorkers=2", + "Use larger runner (requires GitHub Team/Enterprise)", + "Split job into smaller pieces using matrix" + ], + "prevention": "Monitor memory usage and fix memory leaks" + }, + { + "pattern": "Unable to locate executable file", + "category": "environment", + "severity": "error", + "causes": [ + "Tool not pre-installed on runner", + "Wrong runner image", + "PATH not configured" + ], + "fixes": [ + "Use setup action (setup-node, setup-python, etc.)", + "Install tool manually with apt-get or package manager", + "Use container with tool pre-installed" + ], + "prevention": "Check runner software inventory and use setup actions" + }, + { + "pattern": "ENOENT: no such file or directory", + "category": "environment", + "severity": "error", + "causes": [ + "File not checked out from repository", + "Wrong working directory", + "Previous step failed silently" + ], + "fixes": [ + "Add actions/checkout step before using files", + "Set correct working-directory in step", + "Add file existence checks before operations" + ], + "prevention": "Always use actions/checkout first" + }, + { + "pattern": "YAML syntax error|Invalid workflow file", + "category": "syntax", + "severity": "error", + "causes": [ + "Incorrect indentation", + "Missing quotes around special characters", + "Invalid YAML structure" + ], + "fixes": [ + "Run yamllint .github/workflows/", + "Use 2-space indentation consistently (no tabs)", + "Quote strings with special characters", + "Validate YAML syntax online" + ], + "prevention": "Use editor with YAML validation and yamllint pre-commit hook" + }, + { + "pattern": "Could not resolve host", + "category": "network", + "severity": "warning", + "causes": [ + "Temporary DNS issue", + "Service outage", + "Network connectivity problem" + ], + "fixes": [ + "Add retry logic with nick-fields/retry action", + "Use alternative DNS (8.8.8.8)", + "Check service status before proceeding" + ], + "prevention": "Implement retry mechanisms for network operations" + }, + { + "pattern": "API rate limit exceeded", + "category": "network", + "severity": "warning", + "causes": [ + "Too many API requests in short time", + "Missing authentication", + "Shared runner IP rate limited" + ], + "fixes": [ + "Add authentication with GITHUB_TOKEN", + "Add delays between API requests", + "Use GraphQL instead of REST API", + "Cache API responses" + ], + "prevention": "Authenticate all API requests and implement rate limiting" + }, + { + "pattern": "buildx failed|ERROR: failed to solve", + "category": "docker", + "severity": "error", + "causes": [ + "Invalid base image or tag", + "Incorrect COPY/ADD paths", + "Build context doesn't include files" + ], + "fixes": [ + "Use specific image tags (not :latest)", + "Fix COPY paths in Dockerfile", + "Set correct build context", + "Debug with --progress=plain --no-cache" + ], + "prevention": "Test Dockerfile locally and use specific image tags" + } +] diff --git a/.claude/skills/github-actions-debugging/scripts/parse_workflow_logs.py b/.claude/skills/github-actions-debugging/scripts/parse_workflow_logs.py new file mode 100755 index 0000000..c913267 --- /dev/null +++ b/.claude/skills/github-actions-debugging/scripts/parse_workflow_logs.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +GitHub Actions Workflow Log Parser + +This script parses GitHub Actions workflow logs, extracts errors, categorizes them, +and generates actionable fix suggestions. + +Dual Purpose: +1. Executable tool: Run directly to parse log files +2. Reference documentation: Claude can read to understand error patterns + +Usage: + python parse_workflow_logs.py + cat log.txt | python parse_workflow_logs.py +""" + +import re +import sys +import json +from typing import List, Dict, Optional, Tuple +from dataclasses import dataclass, asdict +from enum import Enum + + +class ErrorCategory(Enum): + """Error categories for GitHub Actions failures""" + DEPENDENCY = "dependency" + PERMISSION = "permission" + TIMEOUT = "timeout" + ENVIRONMENT = "environment" + SYNTAX = "syntax" + NETWORK = "network" + DOCKER = "docker" + UNKNOWN = "unknown" + + +class ErrorSeverity(Enum): + """Severity levels for errors""" + CRITICAL = "critical" + WARNING = "warning" + INFO = "info" + + +@dataclass +class ErrorEntry: + """Represents a single error found in logs""" + line_number: int + message: str + category: str + severity: str + context: str + fixes: List[str] + + def to_dict(self) -> dict: + return asdict(self) + + +# Error pattern database - matches against known error signatures +ERROR_PATTERNS = [ + # Dependency errors + (r'npm ERR! code ERESOLVE', ErrorCategory.DEPENDENCY, ErrorSeverity.CRITICAL, + ['Add --legacy-peer-deps flag', 'Update conflicting packages', 'Regenerate package-lock.json']), + + (r'npm ERR!.*EUSAGE.*package\.json and package-lock\.json.*in sync', ErrorCategory.DEPENDENCY, ErrorSeverity.CRITICAL, + ['Run npm install to regenerate lock file', 'Delete package-lock.json and run npm install']), + + (r'pip.*error.*ResolutionImpossible|Cannot install.*incompatible dependencies', ErrorCategory.DEPENDENCY, ErrorSeverity.CRITICAL, + ['Pin conflicting package versions', 'Upgrade pip resolver', 'Use constraint files']), + + (r'go:.*inconsistent vendoring', ErrorCategory.DEPENDENCY, ErrorSeverity.CRITICAL, + ['Run go mod tidy', 'Run go mod vendor', 'Delete vendor/ and regenerate']), + + # Permission errors + (r'Resource not accessible by integration|HttpError.*not accessible', ErrorCategory.PERMISSION, ErrorSeverity.CRITICAL, + ['Add required permissions to workflow', 'Use PAT instead of GITHUB_TOKEN', 'Check organization settings']), + + (r'Permission denied \(publickey\)', ErrorCategory.PERMISSION, ErrorSeverity.CRITICAL, + ['Use HTTPS instead of SSH', 'Configure SSH key with webfactory/ssh-agent', 'Add deploy key to repository']), + + (r'Resource protected by organization SAML enforcement', ErrorCategory.PERMISSION, ErrorSeverity.CRITICAL, + ['Authorize PAT for SAML SSO', 'Create new token with SSO authorization']), + + # Timeout and resource errors + (r'##\[error\].*exceeded the maximum execution time|timeout', ErrorCategory.TIMEOUT, ErrorSeverity.CRITICAL, + ['Increase timeout-minutes in workflow', 'Optimize slow operations', 'Use matrix strategy for parallelization']), + + (r'exit code 137|Killed', ErrorCategory.TIMEOUT, ErrorSeverity.CRITICAL, + ['Increase NODE_OPTIONS --max-old-space-size', 'Reduce parallelism', 'Use larger runner']), + + # Environment errors + (r'Unable to locate executable file|command not found', ErrorCategory.ENVIRONMENT, ErrorSeverity.CRITICAL, + ['Add setup action (setup-node, setup-python)', 'Install tool manually', 'Use container with pre-installed tools']), + + (r'ENOENT: no such file or directory', ErrorCategory.ENVIRONMENT, ErrorSeverity.CRITICAL, + ['Add actions/checkout step', 'Set correct working-directory', 'Check previous steps succeeded']), + + (r'fatal: not a git repository', ErrorCategory.ENVIRONMENT, ErrorSeverity.CRITICAL, + ['Add actions/checkout before git commands', 'Check working directory']), + + # Syntax errors + (r'YAML syntax error|Invalid workflow file|Unexpected token', ErrorCategory.SYNTAX, ErrorSeverity.CRITICAL, + ['Run yamllint on workflow file', 'Fix indentation (use spaces not tabs)', 'Validate YAML syntax']), + + # Network errors + (r'Could not resolve host|getaddrinfo ENOTFOUND', ErrorCategory.NETWORK, ErrorSeverity.WARNING, + ['Add retry logic', 'Check service status', 'Use alternative DNS']), + + (r'API rate limit exceeded|403 Forbidden', ErrorCategory.NETWORK, ErrorSeverity.WARNING, + ['Add authentication to API requests', 'Add delays between requests', 'Use GraphQL instead of REST']), + + # Docker errors + (r'buildx failed|ERROR: failed to solve', ErrorCategory.DOCKER, ErrorSeverity.CRITICAL, + ['Verify base image exists', 'Fix COPY paths in Dockerfile', 'Check build context', 'Use --progress=plain for debugging']), +] + + +def extract_errors(log_text: str) -> List[ErrorEntry]: + """ + Extract error messages and context from GitHub Actions logs. + + Args: + log_text: Raw log text from workflow run + + Returns: + List of ErrorEntry objects with line numbers, messages, and context + """ + errors = [] + lines = log_text.split('\n') + + # Common error indicators in GHA logs + error_indicators = [ + r'##\[error\]', + r'Error:', + r'ERROR:', + r'FAIL:', + r'Failed:', + r'fatal:', + r'npm ERR!', + r'pip error', + ] + + error_pattern = re.compile('|'.join(error_indicators), re.IGNORECASE) + + for i, line in enumerate(lines, start=1): + if error_pattern.search(line): + # Extract context (±5 lines) + start_ctx = max(0, i - 6) + end_ctx = min(len(lines), i + 5) + context = '\n'.join(lines[start_ctx:end_ctx]) + + # Categorize and get fixes + category, severity, fixes = categorize_error(line) + + errors.append(ErrorEntry( + line_number=i, + message=line.strip(), + category=category.value, + severity=severity.value, + context=context, + fixes=fixes + )) + + return errors + + +def categorize_error(error_msg: str) -> Tuple[ErrorCategory, ErrorSeverity, List[str]]: + """ + Match error against known patterns and return category, severity, and fixes. + + Args: + error_msg: Error message to categorize + + Returns: + Tuple of (ErrorCategory, ErrorSeverity, List of fix suggestions) + """ + for pattern, category, severity, fixes in ERROR_PATTERNS: + if re.search(pattern, error_msg, re.IGNORECASE): + return category, severity, fixes + + # Default for unknown errors + return ErrorCategory.UNKNOWN, ErrorSeverity.CRITICAL, ['Review logs for specific error details'] + + +def generate_report(errors: List[ErrorEntry]) -> dict: + """ + Generate structured JSON report from error list. + + Args: + errors: List of ErrorEntry objects + + Returns: + Dictionary with summary and detailed error information + """ + if not errors: + return { + "summary": { + "total_errors": 0, + "categories": {}, + "critical_count": 0 + }, + "errors": [] + } + + # Count errors by category + categories = {} + critical_count = 0 + + for error in errors: + categories[error.category] = categories.get(error.category, 0) + 1 + if error.severity == ErrorSeverity.CRITICAL.value: + critical_count += 1 + + return { + "summary": { + "total_errors": len(errors), + "categories": categories, + "critical_count": critical_count + }, + "errors": [error.to_dict() for error in errors] + } + + +def main(): + """Main entry point for script execution""" + # Read from file or stdin + if len(sys.argv) > 1: + try: + with open(sys.argv[1], 'r', encoding='utf-8') as f: + log_text = f.read() + except FileNotFoundError: + print(f"Error: File '{sys.argv[1]}' not found", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error reading file: {e}", file=sys.stderr) + sys.exit(1) + else: + # Read from stdin + log_text = sys.stdin.read() + + if not log_text.strip(): + print("Error: No input provided", file=sys.stderr) + sys.exit(1) + + # Extract and categorize errors + errors = extract_errors(log_text) + + # Generate report + report = generate_report(errors) + + # Output JSON + print(json.dumps(report, indent=2)) + + # Exit with error code if critical errors found + if report["summary"]["critical_count"] > 0: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.vimrc.bundles.local b/.vimrc.bundles.local index 49a2324..032a0ee 100644 --- a/.vimrc.bundles.local +++ b/.vimrc.bundles.local @@ -15,7 +15,7 @@ NeoBundle 'tpope/vim-fugitive' NeoBundle 'tpope/vim-surround' NeoBundle 'flazz/vim-colorschemes' "TODO: Add vimproc -"TODO: Add Vimshell +NeoBundle 'Shougo/vimshell' "TODO: Add eclim "Vundle NeoBundles! NeoBundle 'Chiel92/vim-autoformat' diff --git a/.zshenv b/.zshenv index 9d37d40..9c7009d 100644 --- a/.zshenv +++ b/.zshenv @@ -5,3 +5,4 @@ case $OS in unsetopt BG_NICE ;; esac +. "$HOME/.cargo/env" diff --git a/stapler-scripts/ark-mod-manager/.gitignore b/stapler-scripts/ark-mod-manager/.gitignore new file mode 100644 index 0000000..d540bd3 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/.gitignore @@ -0,0 +1,23 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Virtual Environment +.venv/ +venv/ +ENV/ + +# Ark Config Backups (Sensitive) +.backups/ +*.bak + +# Temporary Files +.pytest_cache/ +.gemini/tmp/ +inventory_scanner.py +current_inventory.json + +# Local Environment / Secrets +.env +*.local diff --git a/stapler-scripts/ark-mod-manager/.python-version b/stapler-scripts/ark-mod-manager/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/stapler-scripts/ark-mod-manager/README.md b/stapler-scripts/ark-mod-manager/README.md new file mode 100644 index 0000000..e69de29 diff --git a/stapler-scripts/ark-mod-manager/diff_utils.py b/stapler-scripts/ark-mod-manager/diff_utils.py new file mode 100644 index 0000000..00afdcf --- /dev/null +++ b/stapler-scripts/ark-mod-manager/diff_utils.py @@ -0,0 +1,81 @@ +import os +import configparser +import json + +def parse_ini_file(path): + """ + Parses an INI file into a dictionary structure: {Section: {Key: Value}}. + Keys and sections are stored as-is but comparisons should be case-insensitive. + """ + if not os.path.exists(path): + return {} + + config = {} + current_section = None + + try: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + line = line.strip() + if not line or line.startswith(';') or line.startswith('#'): + continue + + if line.startswith('[') and line.endswith(']'): + current_section = line[1:-1] + if current_section not in config: + config[current_section] = {} + continue + + if '=' in line and current_section: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + # Store with original casing, but we might need to normalize for diffing + config[current_section][key] = value + except Exception as e: + print(f"Error reading {path}: {e}") + return {} + + return config + +def diff_configs(base_config, target_config): + """ + Compares target_config AGAINST base_config. + Returns a dictionary of settings that are in target_config but different (or missing) in base_config. + This is effectively the 'Overlay' or 'Patch' needed to transform Base into Target. + """ + diff = {} + + # Normalize base for easier lookup (lowercase keys) + base_lookup = {} + for section, items in base_config.items(): + base_lookup[section.lower()] = {k.lower(): v for k, v in items.items()} + + for section, items in target_config.items(): + section_lower = section.lower() + + for key, value in items.items(): + key_lower = key.lower() + + # Check if this setting exists in base and is the same + in_base = False + if section_lower in base_lookup: + if key_lower in base_lookup[section_lower]: + if base_lookup[section_lower][key_lower] == value: + in_base = True + + if not in_base: + if section not in diff: + diff[section] = {} + diff[section][key] = value + + return diff + +def generate_preset_from_diff(diff_data, profile_name): + """ + Converts a diff dictionary into the structure used by tuning_presets.json + """ + # tuning_presets.json usually separates by "GUS" (GameUserSettings) and "Game" + # We might need heuristics or user input to know which file the diff came from. + # For now, we return the raw structure, and the caller (main.py) assigns it to the right file category. + return diff_data diff --git a/stapler-scripts/ark-mod-manager/main.py b/stapler-scripts/ark-mod-manager/main.py new file mode 100644 index 0000000..99746c9 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/main.py @@ -0,0 +1,456 @@ +import json +import os +import shutil +import re +import argparse +import glob +from datetime import datetime +import diff_utils + +# Paths +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +MAPPING_FILE = os.path.join(SCRIPT_DIR, "mod_mapping.json") +CONFIGS_FILE = os.path.join(SCRIPT_DIR, "mod_configs.json") +PRESETS_FILE = os.path.join(SCRIPT_DIR, "tuning_presets.json") +BACKUP_DIR = os.path.join(SCRIPT_DIR, ".backups") + +STEAM_APPS = os.path.expanduser("~/.local/share/Steam/steamapps/common") +ARK_ROOT = os.path.join(STEAM_APPS, "ARK Survival Ascended") +ARK_CONFIG_DIR = os.path.join(ARK_ROOT, "ShooterGame/Saved/Config/Windows") +GUS_PATH = os.path.join(ARK_CONFIG_DIR, "GameUserSettings.ini") +GAME_INI_PATH = os.path.join(ARK_CONFIG_DIR, "Game.ini") +MODS_DIR = os.path.join(ARK_ROOT, "ShooterGame/Binaries/Win64/ShooterGame/Mods/83374") + +def load_json(path): + if not os.path.exists(path): + return {} + with open(path, 'r') as f: + return json.load(f) + +def save_json(path, data): + with open(path, 'w') as f: + json.dump(data, f, indent=4) + +def perform_backup(file_path): + if not os.path.exists(file_path): + print(f"Warning: File to backup not found: {file_path}") + return False + + if not os.path.exists(BACKUP_DIR): + os.makedirs(BACKUP_DIR) + + filename = os.path.basename(file_path) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_name = f"{filename}.{timestamp}.bak" + backup_path = os.path.join(BACKUP_DIR, backup_name) + + try: + shutil.copy2(file_path, backup_path) + print(f"Backed up {filename} to {backup_path}") + except Exception as e: + print(f"Error creating backup: {e}") + return False + + try: + backups = sorted(glob.glob(os.path.join(BACKUP_DIR, f"{filename}.*.bak"))) + if len(backups) > 50: + to_delete = backups[:-50] + for old_backup in to_delete: + os.remove(old_backup) + print(f"Rotated (deleted) old backup: {os.path.basename(old_backup)}") + except Exception as e: + print(f"Error rotating backups: {e}") + + return True + +def get_active_mods(config_content): + match = re.search(r"ActiveMods=([0-9,]+)", config_content) + if match: + return match.group(1).split(",") + return [] + +def get_installed_mod_ids(): + if not os.path.exists(MODS_DIR): + return [] + ids = [] + for item in os.listdir(MODS_DIR): + match = re.match(r"^(\d+)_", item) + if match: + ids.append(match.group(1)) + return list(set(ids)) + +def get_mod_info(mod_id, mapping): + info = mapping.get(mod_id) + if isinstance(info, str): + return {"name": info, "url": ""} + elif isinstance(info, dict): + return info + return {"name": "Unknown Mod", "url": ""} + +def list_mods(active_mods, mapping, show_all=False): + configs = load_json(CONFIGS_FILE) + installed_ids = get_installed_mod_ids() + + print(f"Mod Status Report:") + print(f" Active: {len(active_mods)}") + print(f" Installed: {len(installed_ids)}") + print("-" * 30) + + print("\n[ENABLED MODS]") + for mod_id in active_mods: + info = get_mod_info(mod_id, mapping) + name = info["name"] + config_status = " [Configured]" if mod_id in configs else "" + print(f" - {mod_id}: {name}{config_status}") + + if show_all: + print("\n[DISABLED MODS (Installed but not active)]") + inactive = sorted([m for m in installed_ids if m not in active_mods]) + if not inactive: + print(" None") + for mod_id in inactive: + info = get_mod_info(mod_id, mapping) + print(f" - {mod_id}: {info['name']}") + +def tag_mod(mod_id, name, mapping): + info = get_mod_info(mod_id, mapping) + info["name"] = name + mapping[mod_id] = info + save_json(MAPPING_FILE, mapping) + print(f"Mapped {mod_id} to '{name}'") + +def scan_local_mods(mapping): + if not os.path.exists(MODS_DIR): + print(f"Mods directory not found: {MODS_DIR}") + return + + print(f"Scanning mods in {MODS_DIR}...") + found_count = 0 + + for item in os.listdir(MODS_DIR): + mod_dir = os.path.join(MODS_DIR, item) + if not os.path.isdir(mod_dir): + continue + match = re.match(r"^(\d+)_", item) + if not match: + continue + + mod_id = match.group(1) + uplugin_files = glob.glob(os.path.join(mod_dir, "**", "*.uplugin"), recursive=True) + + if uplugin_files: + try: + with open(uplugin_files[0], 'r', encoding='utf-8', errors='ignore') as f: + data = json.load(f) + friendly_name = data.get("FriendlyName") + url = data.get("MarketplaceURL", "") + + if friendly_name: + current = get_mod_info(mod_id, mapping) + if current["name"] == "Unknown Mod" or current["name"] != friendly_name or (url and not current["url"]): + mapping[mod_id] = { + "name": friendly_name, + "url": url + } + found_count += 1 + print(f"Updated {mod_id}: {friendly_name}") + except Exception as e: + print(f"Error parsing {uplugin_files[0]}: {e}") + + save_json(MAPPING_FILE, mapping) + print(f"Scan complete. Updated {found_count} mods.") + +def show_mod_info(mod_id, mapping): + info = get_mod_info(mod_id, mapping) + print(f"Mod ID: {mod_id}") + print(f"Name: {info['name']}") + if info['url']: + print(f"URL: {info['url']}") + else: + print("URL: (Not found in local metadata)") + + configs = load_json(CONFIGS_FILE) + if mod_id in configs: + print("\nConfiguration:") + print(json.dumps(configs[mod_id], indent=4)) + else: + print("\nConfiguration: None set locally.") + +def set_ini_value(content, section, key, value): + escaped_section = re.escape(section) + section_pattern = re.compile(fr"^\[{escaped_section}\]", re.MULTILINE) + + if not section_pattern.search(content): + return content + f"\n[{section}]\n{key}={value}\n" + + lines = content.splitlines() + new_lines = [] + in_section = False + key_found = False + + for line in lines: + strip_line = line.strip() + if strip_line.startswith("[") and strip_line.endswith("]"): + if in_section and not key_found: + new_lines.append(f"{key}={value}") + key_found = True + + if strip_line == f"[{section}]": + in_section = True + else: + in_section = False + + if in_section: + if "=" in line: + k, v = line.split("=", 1) + k = k.strip() + if k.lower() == key.lower(): + if v.strip() != str(value): + print(f" Updating {key}: {v.strip()} -> {value}") + new_lines.append(f"{key}={value}") + key_found = True + continue + + new_lines.append(line) + + if in_section and not key_found: + print(f" Adding key {key}={value}") + new_lines.append(f"{key}={value}") + + return "\n".join(new_lines) + +def apply_tuning(profile_name): + presets = load_json(PRESETS_FILE) + if profile_name not in presets: + print(f"Error: Profile '{profile_name}' not found in {PRESETS_FILE}") + return + + print(f"Applying Tuning Profile: '{profile_name}'...") + profile = presets[profile_name] + + if "GUS" in profile and os.path.exists(GUS_PATH): + perform_backup(GUS_PATH) + with open(GUS_PATH, 'r') as f: + content = f.read() + changes_made = False + for section, settings in profile["GUS"].items(): + for key, value in settings.items(): + new_content = set_ini_value(content, section, key, value) + if new_content != content: + content = new_content + changes_made = True + if changes_made: + with open(GUS_PATH, 'w') as f: + f.write(content) + print("Updated GameUserSettings.ini") + else: + print("GameUserSettings.ini is already optimized.") + + if "Game" in profile and os.path.exists(GAME_INI_PATH): + perform_backup(GAME_INI_PATH) + with open(GAME_INI_PATH, 'r') as f: + content = f.read() + changes_made = False + for section, settings in profile["Game"].items(): + for key, value in settings.items(): + new_content = set_ini_value(content, section, key, value) + if new_content != content: + content = new_content + changes_made = True + if changes_made: + with open(GAME_INI_PATH, 'w') as f: + f.write(content) + print("Updated Game.ini") + else: + print("Game.ini is already optimized.") + +def apply_configs(config_path, active_mods, configs): + with open(config_path, 'r') as f: + content = f.read() + original_content = content + updated_mods = list(active_mods) + mods_added = False + for mod_id in configs.keys(): + if mod_id not in updated_mods: + print(f"Activating missing mod {mod_id}") + updated_mods.append(mod_id) + mods_added = True + if mods_added: + new_active_mods_line = "ActiveMods=" + ",".join(updated_mods) + content = re.sub(r"ActiveMods=[0-9,]+", new_active_mods_line, content) + active_mods = updated_mods + + for mod_id, sections in configs.items(): + for section_name, settings in sections.items(): + section_header = f"[{section_name}]" + if section_header not in content: + print(f"Adding section {section_header} for mod {mod_id}") + content += f"\n{section_header}\n" + for k, v in settings.items(): + content += f"{k}={v}\n" + else: + pass + if content != original_content: + with open(config_path, 'w') as f: + f.write(content) + print("Updated configuration file.") + else: + print("Configuration is up to date.") + +def parse_ini_value(content, key): + match = re.search(f"^{key}=(.*)", content, re.MULTILINE | re.IGNORECASE) + if match: + return match.group(1).strip() + return "Default" + +def show_status(): + print("=== Ark Survival Ascended: Server Status ===\n") + if os.path.exists(GUS_PATH): + with open(GUS_PATH, 'r') as f: + gus_content = f.read() + print("[Gameplay Rates]") + print(f" Taming Speed: {parse_ini_value(gus_content, 'TamingSpeedMultiplier')}") + print(f" Harvest Amount: {parse_ini_value(gus_content, 'HarvestAmountMultiplier')}") + print(f" XP Multiplier: {parse_ini_value(gus_content, 'XPMultiplier')}") + print(f" Difficulty Offset: {parse_ini_value(gus_content, 'DifficultyOffset')}") + else: + print("GameUserSettings.ini not found!") + + if os.path.exists(GAME_INI_PATH): + with open(GAME_INI_PATH, 'r') as f: + game_content = f.read() + print("\n[Breeding & Maturation]") + print(f" Mating Interval: {parse_ini_value(game_content, 'MatingIntervalMultiplier')}") + print(f" Egg Hatch Speed: {parse_ini_value(game_content, 'EggHatchSpeedMultiplier')}") + print(f" Baby Mature Speed: {parse_ini_value(game_content, 'BabyMatureSpeedMultiplier')}") + print(f" Cuddle Interval: {parse_ini_value(game_content, 'BabyCuddleIntervalMultiplier')}") + else: + print("\nGame.ini not found!") + +def run_diff(args): + """ + Diff command implementation. + """ + target_path = args.target_file + + if args.base_file: + base_path = args.base_file + print(f"Comparing BASE: {base_path} \n VS\nTARGET: {target_path}") + else: + # Infer base file based on the target filename if possible, otherwise error + target_name = os.path.basename(target_path).lower() + if "gameusersettings" in target_name: + base_path = GUS_PATH + elif "game.ini" in target_name or "gameini" in target_name: + base_path = GAME_INI_PATH + else: + print("Error: Could not infer base configuration type from target filename.") + print("Please specify --base-file explicitly (e.g. path to your active Game.ini).") + return + + print(f"Comparing CURRENT SYSTEM CONFIG ({base_path}) \n VS\nTARGET: {target_path}") + + base_config = diff_utils.parse_ini_file(base_path) + target_config = diff_utils.parse_ini_file(target_path) + + diff = diff_utils.diff_configs(base_config, target_config) + + if not diff: + print("No differences found.") + return + + # Print Diff + print(f"\nDifferences found in {len(diff)} sections:") + for section, items in diff.items(): + print(f"\n [{section}]") + for k, v in items.items(): + print(f" {k} = {v}") + + # Save to Presets + if args.save_as: + profile_name = args.save_as + presets = load_json(PRESETS_FILE) + + # Determine category (GUS vs Game) + target_name = os.path.basename(target_path).lower() + category = "GUS" if "gameusersettings" in target_name else "Game" + + if profile_name not in presets: + presets[profile_name] = {} + + presets[profile_name][category] = diff + save_json(PRESETS_FILE, presets) + print(f"\nSaved differences to profile '{profile_name}' in {PRESETS_FILE}") + print(f"You can apply this overlay using: python3 manage_mods.py tune --profile {profile_name}") + +def main(): + parser = argparse.ArgumentParser(description="Manage Ark Survival Ascended Mods") + subparsers = parser.add_subparsers(dest="command", help="Command to execute") + + list_parser = subparsers.add_parser("list", help="List active mods") + list_parser.add_argument("--all", action="store_true", help="Show all installed mods") + + subparsers.add_parser("apply", help="Apply configurations") + subparsers.add_parser("scan", help="Scan local mod files") + subparsers.add_parser("status", help="Show server gameplay settings") + + tune_parser = subparsers.add_parser("tune", help="Apply optimal gameplay settings") + tune_parser.add_argument("--profile", default="solo", help="Profile name from tuning_presets.json") + + tag_parser = subparsers.add_parser("tag", help="Map a mod ID to a name") + tag_parser.add_argument("mod_id", help="Mod ID") + tag_parser.add_argument("name", help="Mod Name") + + info_parser = subparsers.add_parser("info", help="Show details for a specific mod") + info_parser.add_argument("mod_id", help="Mod ID") + + # New Diff Command + diff_parser = subparsers.add_parser("diff", help="Compare INI files and create overlays") + diff_parser.add_argument("target_file", help="The INI file to import/compare") + diff_parser.add_argument("--base-file", help="The INI file to compare against (defaults to active system config)") + diff_parser.add_argument("--save-as", help="Save the differences as a new tuning profile") + + args = parser.parse_args() + + mapping = load_json(MAPPING_FILE) + configs = load_json(CONFIGS_FILE) + + if args.command == "tag": + tag_mod(args.mod_id, args.name, mapping) + return + elif args.command == "scan": + scan_local_mods(mapping) + return + elif args.command == "status": + show_status() + return + elif args.command == "tune": + apply_tuning(args.profile) + return + elif args.command == "info": + show_mod_info(args.mod_id, mapping) + return + elif args.command == "diff": + run_diff(args) + return + + if not os.path.exists(GUS_PATH): + print(f"Ark config file not found at {GUS_PATH}") + return + + with open(GUS_PATH, 'r') as f: + raw_content = f.read() + + active_mods = get_active_mods(raw_content) + + if args.command == "list": + list_mods(active_mods, mapping, show_all=args.all) + elif args.command == "apply" or args.command is None: + if not perform_backup(GUS_PATH): + return + apply_configs(GUS_PATH, active_mods, configs) + else: + parser.print_help() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/stapler-scripts/ark-mod-manager/manage_mods.py b/stapler-scripts/ark-mod-manager/manage_mods.py new file mode 100644 index 0000000..82afe9c --- /dev/null +++ b/stapler-scripts/ark-mod-manager/manage_mods.py @@ -0,0 +1,461 @@ +import json +import os +import shutil +import re +import argparse +import glob +from datetime import datetime +import diff_utils + +# Paths +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +MAPPING_FILE = os.path.join(SCRIPT_DIR, "mod_mapping.json") +CONFIGS_FILE = os.path.join(SCRIPT_DIR, "mod_configs.json") +PRESETS_FILE = os.path.join(SCRIPT_DIR, "tuning_presets.json") +BACKUP_DIR = os.path.join(SCRIPT_DIR, ".backups") + +STEAM_APPS = os.path.expanduser("~/.local/share/Steam/steamapps/common") +ARK_ROOT = os.path.join(STEAM_APPS, "ARK Survival Ascended") +ARK_CONFIG_DIR = os.path.join(ARK_ROOT, "ShooterGame/Saved/Config/Windows") +GUS_PATH = os.path.join(ARK_CONFIG_DIR, "GameUserSettings.ini") +GAME_INI_PATH = os.path.join(ARK_CONFIG_DIR, "Game.ini") +MODS_DIR = os.path.join(ARK_ROOT, "ShooterGame/Binaries/Win64/ShooterGame/Mods/83374") + +def load_json(path): + if not os.path.exists(path): + return {} + with open(path, 'r') as f: + return json.load(f) + +def save_json(path, data): + with open(path, 'w') as f: + json.dump(data, f, indent=4) + +def perform_backup(file_path): + if not os.path.exists(file_path): + print(f"Warning: File to backup not found: {file_path}") + return False + + if not os.path.exists(BACKUP_DIR): + os.makedirs(BACKUP_DIR) + + filename = os.path.basename(file_path) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_name = f"{filename}.{timestamp}.bak" + backup_path = os.path.join(BACKUP_DIR, backup_name) + + try: + shutil.copy2(file_path, backup_path) + print(f"Backed up {filename} to {backup_path}") + except Exception as e: + print(f"Error creating backup: {e}") + return False + + try: + backups = sorted(glob.glob(os.path.join(BACKUP_DIR, f"{filename}.*.bak"))) + if len(backups) > 50: + to_delete = backups[:-50] + for old_backup in to_delete: + os.remove(old_backup) + print(f"Rotated (deleted) old backup: {os.path.basename(old_backup)}") + except Exception as e: + print(f"Error rotating backups: {e}") + + return True + +def get_active_mods(config_content): + match = re.search(r"ActiveMods=([0-9,]+)", config_content) + if match: + return match.group(1).split(",") + return [] + +def get_installed_mod_ids(): + if not os.path.exists(MODS_DIR): + return [] + ids = [] + for item in os.listdir(MODS_DIR): + match = re.match(r"^(\d+)_", item) + if match: + ids.append(match.group(1)) + return list(set(ids)) + +def get_mod_info(mod_id, mapping): + info = mapping.get(mod_id) + if isinstance(info, str): + return {"name": info, "url": ""} + elif isinstance(info, dict): + return info + return {"name": "Unknown Mod", "url": ""} + +def list_mods(active_mods, mapping, show_all=False): + configs = load_json(CONFIGS_FILE) + installed_ids = get_installed_mod_ids() + + print(f"Mod Status Report:") + print(f" Active: {len(active_mods)}") + print(f" Installed: {len(installed_ids)}") + print("-" * 30) + + print("\n[ENABLED MODS]") + for mod_id in active_mods: + info = get_mod_info(mod_id, mapping) + name = info["name"] + config_status = " [Configured]" if mod_id in configs else "" + print(f" - {mod_id}: {name}{config_status}") + + if show_all: + print("\n[DISABLED MODS (Installed but not active)]") + inactive = sorted([m for m in installed_ids if m not in active_mods]) + if not inactive: + print(" None") + for mod_id in inactive: + info = get_mod_info(mod_id, mapping) + print(f" - {mod_id}: {info['name']}") + +def tag_mod(mod_id, name, mapping): + # Preserve existing URL if updating name + info = get_mod_info(mod_id, mapping) + info["name"] = name + mapping[mod_id] = info + save_json(MAPPING_FILE, mapping) + print(f"Mapped {mod_id} to '{name}'") + +def scan_local_mods(mapping): + if not os.path.exists(MODS_DIR): + print(f"Mods directory not found: {MODS_DIR}") + return + + print(f"Scanning mods in {MODS_DIR}...") + found_count = 0 + + for item in os.listdir(MODS_DIR): + mod_dir = os.path.join(MODS_DIR, item) + if not os.path.isdir(mod_dir): + continue + match = re.match(r"^(\d+)_", item) + if not match: + continue + + mod_id = match.group(1) + uplugin_files = glob.glob(os.path.join(mod_dir, "**", "*.uplugin"), recursive=True) + + if uplugin_files: + try: + with open(uplugin_files[0], 'r', encoding='utf-8', errors='ignore') as f: + data = json.load(f) + friendly_name = data.get("FriendlyName") + url = data.get("MarketplaceURL", "") + + if friendly_name: + # Normalize mapping entry + current = get_mod_info(mod_id, mapping) + + # Update if new info or previously unknown + if current["name"] == "Unknown Mod" or current["name"] != friendly_name or (url and not current["url"]): + mapping[mod_id] = { + "name": friendly_name, + "url": url + } + found_count += 1 + print(f"Updated {mod_id}: {friendly_name}") + except Exception as e: + print(f"Error parsing {uplugin_files[0]}: {e}") + + save_json(MAPPING_FILE, mapping) + print(f"Scan complete. Updated {found_count} mods.") + +def show_mod_info(mod_id, mapping): + info = get_mod_info(mod_id, mapping) + print(f"Mod ID: {mod_id}") + print(f"Name: {info['name']}") + if info['url']: + print(f"URL: {info['url']}") + else: + print("URL: (Not found in local metadata)") + + # Check if configured + configs = load_json(CONFIGS_FILE) + if mod_id in configs: + print("\nConfiguration:") + print(json.dumps(configs[mod_id], indent=4)) + else: + print("\nConfiguration: None set locally.") + +def set_ini_value(content, section, key, value): + escaped_section = re.escape(section) + section_pattern = re.compile(fr"^\[{escaped_section}\]", re.MULTILINE) + + if not section_pattern.search(content): + print(f" Adding missing section: [{section}]") + return content + f"\n[{section}]\n{key}={value}\n" + + lines = content.splitlines() + new_lines = [] + in_section = False + key_found = False + + for line in lines: + strip_line = line.strip() + if strip_line.startswith("[") and strip_line.endswith("]"): + if in_section and not key_found: + print(f" Adding key {key}={value}") + new_lines.append(f"{key}={value}") + key_found = True + + if strip_line == f"[{section}]": + in_section = True + else: + in_section = False + + if in_section: + if "=" in line: + k, v = line.split("=", 1) + k = k.strip() + if k.lower() == key.lower(): + if v.strip() != str(value): + print(f" Updating {key}: {v.strip()} -> {value}") + new_lines.append(f"{key}={value}") + key_found = True + continue + + new_lines.append(line) + + if in_section and not key_found: + print(f" Adding key {key}={value}") + new_lines.append(f"{key}={value}") + + return "\n".join(new_lines) + +def apply_tuning(profile_name): + presets = load_json(PRESETS_FILE) + if profile_name not in presets: + print(f"Error: Profile '{profile_name}' not found in {PRESETS_FILE}") + return + + print(f"Applying Tuning Profile: '{profile_name}'...") + profile = presets[profile_name] + + if "GUS" in profile and os.path.exists(GUS_PATH): + perform_backup(GUS_PATH) + with open(GUS_PATH, 'r') as f: + content = f.read() + changes_made = False + for section, settings in profile["GUS"].items(): + for key, value in settings.items(): + new_content = set_ini_value(content, section, key, value) + if new_content != content: + content = new_content + changes_made = True + if changes_made: + with open(GUS_PATH, 'w') as f: + f.write(content) + print("Updated GameUserSettings.ini") + else: + print("GameUserSettings.ini is already optimized.") + + if "Game" in profile and os.path.exists(GAME_INI_PATH): + perform_backup(GAME_INI_PATH) + with open(GAME_INI_PATH, 'r') as f: + content = f.read() + changes_made = False + for section, settings in profile["Game"].items(): + for key, value in settings.items(): + new_content = set_ini_value(content, section, key, value) + if new_content != content: + content = new_content + changes_made = True + if changes_made: + with open(GAME_INI_PATH, 'w') as f: + f.write(content) + print("Updated Game.ini") + else: + print("Game.ini is already optimized.") + +def apply_configs(config_path, active_mods, configs): + with open(config_path, 'r') as f: + content = f.read() + original_content = content + updated_mods = list(active_mods) + mods_added = False + for mod_id in configs.keys(): + if mod_id not in updated_mods: + print(f"Activating missing mod {mod_id}") + updated_mods.append(mod_id) + mods_added = True + if mods_added: + new_active_mods_line = "ActiveMods=" + ",".join(updated_mods) + content = re.sub(r"ActiveMods=[0-9,]+", new_active_mods_line, content) + active_mods = updated_mods + + for mod_id, sections in configs.items(): + for section_name, settings in sections.items(): + section_header = f"[{section_name}]" + if section_header not in content: + print(f"Adding section {section_header} for mod {mod_id}") + content += f"\n{section_header}\n" + for k, v in settings.items(): + content += f"{k}={v}\n" + else: + pass + if content != original_content: + with open(config_path, 'w') as f: + f.write(content) + print("Updated configuration file.") + else: + print("Configuration is up to date.") + +def parse_ini_value(content, key): + match = re.search(f"^{key}=(.*)", content, re.MULTILINE | re.IGNORECASE) + if match: + return match.group(1).strip() + return "Default" + +def show_status(): + print("=== Ark Survival Ascended: Server Status ===\n") + if os.path.exists(GUS_PATH): + with open(GUS_PATH, 'r') as f: + gus_content = f.read() + print("[Gameplay Rates]") + print(f" Taming Speed: {parse_ini_value(gus_content, 'TamingSpeedMultiplier')}") + print(f" Harvest Amount: {parse_ini_value(gus_content, 'HarvestAmountMultiplier')}") + print(f" XP Multiplier: {parse_ini_value(gus_content, 'XPMultiplier')}") + print(f" Difficulty Offset: {parse_ini_value(gus_content, 'DifficultyOffset')}") + print("\n[Display Settings]") + res_x = parse_ini_value(gus_content, 'ResolutionSizeX') + res_y = parse_ini_value(gus_content, 'ResolutionSizeY') + print(f" Resolution: {res_x}x{res_y}") + print(f" Fullscreen Mode: {parse_ini_value(gus_content, 'FullscreenMode')} (0=Windowed, 1=WindowedFullscreen, 2=Fullscreen)") + else: + print("GameUserSettings.ini not found!") + + if os.path.exists(GAME_INI_PATH): + with open(GAME_INI_PATH, 'r') as f: + game_content = f.read() + print("\n[Breeding & Maturation]") + print(f" Mating Interval: {parse_ini_value(game_content, 'MatingIntervalMultiplier')}") + print(f" Egg Hatch Speed: {parse_ini_value(game_content, 'EggHatchSpeedMultiplier')}") + print(f" Baby Mature Speed: {parse_ini_value(game_content, 'BabyMatureSpeedMultiplier')}") + print(f" Cuddle Interval: {parse_ini_value(game_content, 'BabyCuddleIntervalMultiplier')}") + else: + print("\nGame.ini not found!") + +def run_diff(args): + target_path = args.target_file + + if args.base_file: + base_path = args.base_file + print(f"Comparing BASE: {base_path} \n VS\nTARGET: {target_path}") + else: + target_name = os.path.basename(target_path).lower() + if "gameusersettings" in target_name: + base_path = GUS_PATH + elif "game.ini" in target_name or "gameini" in target_name: + base_path = GAME_INI_PATH + else: + print("Error: Could not infer base configuration type from target filename.") + print("Please specify --base-file explicitly (e.g. path to your active Game.ini).") + return + + print(f"Comparing CURRENT SYSTEM CONFIG ({base_path}) \n VS\nTARGET: {target_path}") + + base_config = diff_utils.parse_ini_file(base_path) + target_config = diff_utils.parse_ini_file(target_path) + + diff = diff_utils.diff_configs(base_config, target_config) + + if not diff: + print("No differences found.") + return + + print(f"\nDifferences found in {len(diff)} sections:") + for section, items in diff.items(): + print(f"\n [{section}]") + for k, v in items.items(): + print(f" {k} = {v}") + + if args.save_as: + profile_name = args.save_as + presets = load_json(PRESETS_FILE) + + target_name = os.path.basename(target_path).lower() + category = "GUS" if "gameusersettings" in target_name else "Game" + + if profile_name not in presets: + presets[profile_name] = {} + + presets[profile_name][category] = diff + save_json(PRESETS_FILE, presets) + print(f"\nSaved differences to profile '{profile_name}' in {PRESETS_FILE}") + print(f"You can apply this overlay using: python3 manage_mods.py tune --profile {profile_name}") + +def main(): + parser = argparse.ArgumentParser(description="Manage Ark Survival Ascended Mods") + subparsers = parser.add_subparsers(dest="command", help="Command to execute") + + list_parser = subparsers.add_parser("list", help="List active mods") + list_parser.add_argument("--all", action="store_true", help="Show all installed mods, including disabled ones") + + subparsers.add_parser("apply", help="Apply configurations") + subparsers.add_parser("scan", help="Scan local mod files for names/URLs") + subparsers.add_parser("status", help="Show server gameplay settings") + + tune_parser = subparsers.add_parser("tune", help="Apply optimal gameplay settings") + tune_parser.add_argument("--profile", default="solo", help="Profile name from tuning_presets.json") + + tag_parser = subparsers.add_parser("tag", help="Map a mod ID to a name") + tag_parser.add_argument("mod_id", help="Mod ID") + tag_parser.add_argument("name", help="Mod Name") + + info_parser = subparsers.add_parser("info", help="Show details for a specific mod") + info_parser.add_argument("mod_id", help="Mod ID") + + # New Diff Command + diff_parser = subparsers.add_parser("diff", help="Compare INI files and create overlays") + diff_parser.add_argument("target_file", help="The INI file to import/compare") + diff_parser.add_argument("--base-file", help="The INI file to compare against (defaults to active system config)") + diff_parser.add_argument("--save-as", help="Save the differences as a new tuning profile") + + args = parser.parse_args() + + mapping = load_json(MAPPING_FILE) + configs = load_json(CONFIGS_FILE) + + if args.command == "tag": + tag_mod(args.mod_id, args.name, mapping) + return + elif args.command == "scan": + scan_local_mods(mapping) + return + elif args.command == "status": + show_status() + return + elif args.command == "tune": + apply_tuning(args.profile) + return + elif args.command == "info": + show_mod_info(args.mod_id, mapping) + return + elif args.command == "diff": + run_diff(args) + return + + if not os.path.exists(GUS_PATH): + print(f"Ark config file not found at {GUS_PATH}") + return + + with open(GUS_PATH, 'r') as f: + raw_content = f.read() + + active_mods = get_active_mods(raw_content) + + if args.command == "list": + list_mods(active_mods, mapping, show_all=args.all) + elif args.command == "apply" or args.command is None: + if not perform_backup(GUS_PATH): + return + apply_configs(GUS_PATH, active_mods, configs) + else: + parser.print_help() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/stapler-scripts/ark-mod-manager/mod_configs.json b/stapler-scripts/ark-mod-manager/mod_configs.json new file mode 100644 index 0000000..50183d0 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/mod_configs.json @@ -0,0 +1,32 @@ +{ + "928793": { + "Cryopods": { + "ForceUseINISettings": "True", + "DisableCryoSickness": "True", + "DisableCryopodChargeNeed": "True", + "CryoTime": "0.1", + "CryoTimeInCombat": "5.0", + "AllowDeployInBossArenas": "True", + "PassImprintToDeployer": "True" + } + }, + "928597": { + "AutomatedArk": { + "CraftingSpeedMultiplier": "2", + "ConsolePullRange": "10000", + "ConsolePullTimer": "300", + "UnlockAllEngrams": "True", + "DisableGrinderElement": "True" + } + }, + "1220415": { + "HypersDinoWipe": { + "BoolUseAutomatedDinoWipes": "True", + "FloatWipeIntervalInSeconds": "3600.0", + "BoolWipeDinosOnServerStart": "True", + "BoolNoWipeTamedDino": "True", + "BoolNoWipeSleepingDino": "True", + "BoolShowWipeProgress": "True" + } + } +} diff --git a/stapler-scripts/ark-mod-manager/mod_mapping.json b/stapler-scripts/ark-mod-manager/mod_mapping.json new file mode 100644 index 0000000..aa16a29 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/mod_mapping.json @@ -0,0 +1,382 @@ +{ + "930561": { + "name": "Dazza's Stacking Mod + Craftable Element", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/dazzas-stacking-mod-craftable-element" + }, + "947033": { + "name": "AwesomeSpyGlass!", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/awesomespyglass" + }, + "931874": { + "name": "Arkitect Structures Remastered", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/arkitect-structures-remastered" + }, + "928793": { + "name": "Cryopods", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/cryopods" + }, + "928597": { + "name": "Automated Ark", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/automated-ark" + }, + "912902": { + "name": "Additions Ascended: Deinosuchus!", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/additions-ascended-deinosuchus" + }, + "900062": { + "name": "Additions Ascended: Ceratosaurus!", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/aaceratosaurus_test" + }, + "928501": { + "name": "Solo Farm Mod", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/solo-farm-mod" + }, + "928621": { + "name": "Utilities Plus", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/utilities-plus" + }, + "912815": { + "name": "S-Dino Variants", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/s-dino-variants" + }, + "929420": { + "name": "Super Spyglass Plus", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/super-spyglass-plus" + }, + "975282": { + "name": "Gigantoraptor", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/gigantoraptor" + }, + "927131": { + "name": "Additions Ascended: Brachiosaurus", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additions-ascended-brachiosaurus" + }, + "927090": { + "name": "Winter Wonderland", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/winter-wonderland" + }, + "953154": { + "name": "Auto Engrams", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/auto-engrams" + }, + "950914": { + "name": "AwesomeTeleporters!", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/awesometeleporters" + }, + "933099": { + "name": "Super Cryo Storage", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/super-cryo-storage" + }, + "929489": { + "name": "Draconic Chronicles (Crossplay) (Dragons, Wyverns and other Draconic Creatures)", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/draconic-chronicles" + }, + "916922": { + "name": "Additions Ascended: Helicoprion!", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/additions-ascended-helicoprion" + }, + "940975": { + "name": "Cybers Structures", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/cybers-structures" + }, + "908148": { + "name": "Additions Ascended: Xiphactinus!", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/aaxiphactinus_test" + }, + "926956": { + "name": "Additions Ascended: Archelon!", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additions-ascended-archelon" + }, + "938805": { + "name": "Discovery World", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/discovery-world" + }, + "914844": { + "name": "Additions Ascended: Deinotherium!", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/additions-ascended-deinotherium" + }, + "928548": { + "name": "Shiny Ascended", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/shiny-ascended" + }, + "926259": { + "name": "Additions Ascended: Acrocanthosaurus!", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additions-ascended-acro-not-finished" + }, + "929299": { + "name": "Stop The Steal - Ascended", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/stop-the-steal-ascended" + }, + "939228": { + "name": "QoL+", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/qol" + }, + "940022": { + "name": "Pull It!", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/pull-it" + }, + "929578": { + "name": "AP: Death Recovery", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/ap-death-recovery" + }, + "929543": { + "name": "Imbue Station", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/imbue-station" + }, + "959391": { + "name": "ARK Wilds: Sivatherium", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/sivatherium" + }, + "932789": { + "name": "Additional Ammunition", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additional-ammunition" + }, + "928824": { + "name": "Moros Indomitable Duo", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/moros-indomitable-duo" + }, + "1007609": { + "name": "Cyrus' Critters: Jumping Spider", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/cyrus-critters-jumping-spider" + }, + "970540": { + "name": "Paleo ARK - Evolution | Apex Predators", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/paleo-ark-evolution-apex-predators" + }, + "1038262": { + "name": "Moros Nothosaurus", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/moros-nothosaurus" + }, + "961162": { + "name": "BigAL's: Meraxes TLC", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/bigals-meraxes-tlc" + }, + "965961": { + "name": "Cyrus' Critters: Magna Gecko", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/cyrus-critters" + }, + "930601": { + "name": "Dino Retrieval Terminal", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/dino-retrieval-terminal" + }, + "972484": { + "name": "Paleo ARK EVO+ | Native Aquatics", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/paleo-ark-evo-native-aquatics" + }, + "939688": { + "name": "Lily's Tweaker", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/lilys-tweaker" + }, + "929271": { + "name": "Additional Lights", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additional-lights" + }, + "930170": { + "name": "Cliffan Backpacks", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/cliffan-backpacks" + }, + "935985": { + "name": "Loot Grabber", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/loot-grabber" + }, + "928650": { + "name": "Gaia: Potions", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/gaia-potions" + }, + "954038": { + "name": "Additional Creatures: Endemics", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additional-creatures-endemics" + }, + "1040043": { + "name": "Additional Creatures: Paranoia", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additional-creatures-paranoia" + }, + "1009115": { + "name": "Additional Creatures: Wild Ark", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/additional-creatures-wild-ark" + }, + "1074189": { + "name": "Creature Spawns (Oasisaur)", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/creature-spawns-oasisaur" + }, + "930494": { + "name": "Upgrade Station", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/upgrade-station" + }, + "936457": { + "name": "Admin Commands", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/admin-commands" + }, + "929330": { + "name": "J-Collectors", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/j-collectors" + }, + "963648": { + "name": "ATJ Creature Additions (Cross platform)", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/atj-creature-additions-cross-platform" + }, + "930115": { + "name": "Gryphons", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/gryphons" + }, + "1220415": { + "name": "Hyper's Dynamic Dino Wipe And Population Control", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/hypers-dynamic-dino-wipe-and-population-control" + }, + "965379": { + "name": "Amissa", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/amissa" + }, + "975626": { + "name": "Reverence", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/reverence" + }, + "954190": { + "name": "Arkopolis", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/arkopolis" + }, + "965599": { + "name": "Nyrandil", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/nyrandil" + }, + "1064776": { + "name": "Test Test", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/test-test" + }, + "1056795": { + "name": "Barsboldia Beta Test", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/barsboldia-beta-test" + }, + "933078": { + "name": "Ascended Cosmetics", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/ascended-cosmetics" + }, + "928539": { + "name": "Appetizer Beta", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/appetizer-beta" + }, + "937143": { + "name": "APA Galvarex", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/apa-galvarex" + }, + "949947": { + "name": "Deimos variants: D-TekRex", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/deimos-variants-d-tekrex" + }, + "1050566": { + "name": "Feral Fauna: Majungasaurus", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/feral-fauna-majungasaurus-testing" + }, + "1013349": { + "name": "Arketypes: Bombardier Beetle", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/arketypes-bombardier-beetle" + }, + "1097188": { + "name": "Custom Creations: Dracoteuthis", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/custom-creations-dracoteuthis" + }, + "963130": { + "name": "Tristan's Additional Creatures Eocarcharia", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/tristans-additional-creatures-eocarcharia" + }, + "1087762": { + "name": "ARK Wilds: Cockatrice", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/cockatrice" + }, + "1099319": { + "name": "Isla Nycta's Nyctatyrannus", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/isla-nyctas-nyctatyrannus" + }, + "1124039": { + "name": "Better Bigfoots", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/better-bigfoots" + }, + "1095961": { + "name": "Retrosauria Assemblage: Laelaps Test", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/retrosauria-assemblage-laelaps-test" + }, + "1008968": { + "name": "Better Rock Golem", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/better-rock-golem" + }, + "936564": { + "name": "Better Tapejara!", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/better-tapejara" + }, + "958032": { + "name": "Reborn: Direwolf", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/reborn-direwolf" + }, + "1069445": { + "name": "Spinosaurus Rex", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/spinosaurus-rex" + }, + "1007223": { + "name": "CoKiToS Element Gathering Ankylosaurus", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/cokitos-element-gathering-ankylosaurus" + }, + "938642": { + "name": "Better Oviraptor", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/better-oviraptor" + }, + "1108212": { + "name": "Better Therizinosaur", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/better-therizinosaur" + }, + "930442": { + "name": "Spawn Blocker", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/spawn-blocker" + }, + "983782": { + "name": "Dear Jane", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/dear-jane" + }, + "974338": { + "name": "Cliffans Saddles Custom Cosmetics", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/cliffans-saddles-custom-cosmetics" + }, + "985695": { + "name": "Monolophosaurus Test", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/monolophosaurus-test" + }, + "944345": { + "name": "RR-Otodontidae Sharks", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/rr-otodontidae-sharks" + }, + "1058624": { + "name": "Tristan's Additional Creatures: Antrodemus-Beta", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/tristans-additional-creatures-antrodemus-beta" + }, + "1067560": { + "name": "Forgotten Fauna Continued: Monolophosaurus", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/forgotten-fauna-continued-monolophosaurus" + }, + "937546": { + "name": "Dino+", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/dino" + }, + "974472": { + "name": "Prehistoric Beasts Part III", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/prehistoric-beasts-part-iii" + }, + "1005639": { + "name": "Club ARK", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/club-ark" + }, + "1382641": { + "name": "Paleo ARK - Evolution | Hard Hitting Herbivores", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/paleo-ark-evolution-hard-hitting-herbivores" + }, + "941697": { + "name": "Better Breeding", + "url": "https://legacy.curseforge.com/ark-survival-ascended/mods/better-breeding" + }, + "931607": { + "name": "Starter Kits", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/starter-kits" + }, + "939055": { + "name": "ARKomatic", + "url": "https://www.curseforge.com/ark-survival-ascended/mods/arkomatic" + } +} \ No newline at end of file diff --git a/stapler-scripts/ark-mod-manager/pyproject.toml b/stapler-scripts/ark-mod-manager/pyproject.toml new file mode 100644 index 0000000..890b8af --- /dev/null +++ b/stapler-scripts/ark-mod-manager/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "ark-mod-manager" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "beautifulsoup4>=4.14.3", + "requests>=2.32.5", +] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] + +[tool.uv.workspace] +members = [ + "experiments", +] diff --git a/stapler-scripts/ark-mod-manager/test_manage_mods.py b/stapler-scripts/ark-mod-manager/test_manage_mods.py new file mode 100644 index 0000000..74b8ff3 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/test_manage_mods.py @@ -0,0 +1,67 @@ +import pytest +from unittest.mock import patch, mock_open, MagicMock +import manage_mods +import os +import json + +# Sample Config Content +SAMPLE_CONFIG = """ +[ServerSettings] +ActiveMods=12345,67890 +""" + +def test_get_active_mods(): + mods = manage_mods.get_active_mods(SAMPLE_CONFIG) + assert mods == ["12345", "67890"] + +@patch("manage_mods.load_json") +@patch("builtins.print") +def test_list_mods(mock_print, mock_load): + mock_load.side_effect = [ + {"12345": "Mod A"}, # mapping + {"12345": {"S": {"K": "V"}}} # configs + ] + manage_mods.list_mods(["12345", "67890"], {"12345": "Mod A"}) + # Verify print was called for both mods + calls = [call[0][0] for call in mock_print.call_args_list] + assert any("Mod A" in c for m in calls for c in [m]) + assert any("Unknown Mod" in c for m in calls for c in [m]) + +@patch("manage_mods.save_json") +def test_tag_mod(mock_save): + mapping = {} + manage_mods.tag_mod("111", "Name", mapping) + assert mapping["111"] == "Name" + mock_save.assert_called() + +@patch("builtins.open", new_callable=mock_open, read_data=SAMPLE_CONFIG) +def test_apply_configs_adds_section(mock_file): + configs = { + "99999": { + "NewModSection": { + "SomeKey": "SomeValue" + } + } + } + # 99999 must be in active_mods for it to apply + manage_mods.apply_configs("fake_path.ini", ["12345", "99999"], configs) + + handle = mock_file() + written_content = handle.write.call_args[0][0] + assert "[NewModSection]" in written_content + assert "SomeKey=SomeValue" in written_content + +@patch("builtins.open", new_callable=mock_open, read_data=SAMPLE_CONFIG) +def test_apply_configs_activates_missing_mod(mock_file): + mod_id = "11111" + configs = { + mod_id: { + "ModSection": {"Key": "Val"} + } + } + + manage_mods.apply_configs("fake_path.ini", ["12345", "67890"], configs) + + handle = mock_file() + written_content = handle.write.call_args[0][0] + assert f"ActiveMods=12345,67890,{mod_id}" in written_content \ No newline at end of file diff --git a/stapler-scripts/ark-mod-manager/tuning_presets.json b/stapler-scripts/ark-mod-manager/tuning_presets.json new file mode 100644 index 0000000..99c0905 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/tuning_presets.json @@ -0,0 +1,320 @@ +{ + "solo": { + "GUS": { + "ServerSettings": { + "DifficultyOffset": "1.0", + "OverrideOfficialDifficulty": "5.0", + "XPMultiplier": "3.0", + "TamingSpeedMultiplier": "10.0", + "HarvestAmountMultiplier": "3.0", + "HarvestHealthMultiplier": "2.0", + "ResourcesRespawnPeriodMultiplier": "0.5", + "PlayerCharacterWaterDrainMultiplier": "0.5", + "PlayerCharacterFoodDrainMultiplier": "0.5", + "bAllowFlyerSpeedLeveling": "True", + "bDisableStructurePlacementCollision": "True", + "MaxTamedDinos": "5000", + "ShowFloatingDamageText": "True", + "DisableStructureDecayPvE": "True", + "AllowFlyerCarryPvE": "True", + "ForceAllowCaveFlyers": "True", + "bUseSingleplayerSettings": "True", + "AlwaysAllowStructurePickup": "True", + "StructurePickupTimeAfterPlacement": "30" + } + }, + "Game": { + "/Script/ShooterGame.ShooterGameMode": { + "MatingIntervalMultiplier": "0.01", + "EggHatchSpeedMultiplier": "50.0", + "BabyMatureSpeedMultiplier": "50.0", + "BabyCuddleIntervalMultiplier": "0.05", + "BabyImprintingStatScaleMultiplier": "1.0", + "BabyCuddleGracePeriodMultiplier": "10.0", + "BabyCuddleLoseImprintQualitySpeedMultiplier": "0.1", + "PerLevelStatsMultiplier_Player[7]": "5.0", + "PerLevelStatsMultiplier_DinoTamed[7]": "5.0", + "GlobalSpoilingTimeMultiplier": "2.0", + "GlobalItemDecompositionTimeMultiplier": "2.0", + "GlobalCorpseDecompositionTimeMultiplier": "2.0" + } + } + }, + "solo_server": { + "GUS": { + "ServerSettings": { + "DifficultyOffset": "1.0", + "OverrideOfficialDifficulty": "5.0", + "XPMultiplier": "3.0", + "TamingSpeedMultiplier": "10.0", + "HarvestAmountMultiplier": "3.0", + "HarvestHealthMultiplier": "2.0", + "ResourcesRespawnPeriodMultiplier": "0.5", + "PlayerCharacterWaterDrainMultiplier": "0.5", + "PlayerCharacterFoodDrainMultiplier": "0.5", + "bAllowFlyerSpeedLeveling": "True", + "bDisableStructurePlacementCollision": "True", + "MaxTamedDinos": "5000", + "ShowFloatingDamageText": "True", + "DisableStructureDecayPvE": "True", + "AllowFlyerCarryPvE": "True", + "ForceAllowCaveFlyers": "True", + "bUseSingleplayerSettings": "True", + "AlwaysAllowStructurePickup": "True", + "StructurePickupTimeAfterPlacement": "30" + } + }, + "Game": { + "/Script/ShooterGame.ShooterGameMode": { + "MatingIntervalMultiplier": "0.01", + "EggHatchSpeedMultiplier": "50.0", + "BabyMatureSpeedMultiplier": "50.0", + "BabyCuddleIntervalMultiplier": "0.05", + "BabyImprintingStatScaleMultiplier": "1.0", + "BabyCuddleGracePeriodMultiplier": "10.0", + "BabyCuddleLoseImprintQualitySpeedMultiplier": "0.1", + "PerLevelStatsMultiplier_Player[7]": "5.0", + "PerLevelStatsMultiplier_DinoTamed[7]": "5.0", + "GlobalSpoilingTimeMultiplier": "2.0", + "GlobalItemDecompositionTimeMultiplier": "2.0", + "GlobalCorpseDecompositionTimeMultiplier": "2.0" + } + } + }, + "fun_optimized": { + "GUS": { + "ServerSettings": { + "NightTimeSpeedScale": "2.0", + "DayTimeSpeedScale": "0.5", + "PlayerCharacterStaminaDrainMultiplier": "0.5", + "ResourceNoReplenishRadiusPlayers": "0.1", + "ResourceNoReplenishRadiusStructures": "0.1" + } + }, + "Game": { + "/Script/ShooterGame.ShooterGameMode": { + "BabyCuddleIntervalMultiplier": "0.02" + } + } + }, + "ark_2025_overlay": { + "Game": { + "/script/shootergame.shootergamemode": { + "BabyCuddleGracePeriodMultiplier": "1", + "BabyCuddleIntervalMultiplier": "0.0184000004", + "BabyCuddleLoseImprintQualitySpeedMultiplier": "1", + "BabyFoodConsumptionSpeedMultiplier": "1", + "BabyImprintAmountMultiplier": "1", + "BabyImprintingStatScaleMultiplier": "1", + "BabyMatureSpeedMultiplier": "244", + "bAutoUnlockAllEngrams": "False", + "bDisableDinoDecayClaiming": "False", + "bDisableStructurePlacementCollision": "True", + "bFlyerPlatformAllowUnalignedDinoBasing": "True", + "bOnlyAllowSpecifiedEngrams": "False", + "BossKillXPMultiplier": "2", + "bPassiveDefensesDamageRiderlessDinos": "True", + "bPvEAllowTribeWarCancel": "True", + "ConfigAddNPCSpawnEntriesContainer": "(NPCSpawnEntriesContainerClassString=\"DinoSpawnEntriesSnow_C\",NPCSpawnEntries=((AnEntryName=\"Daeodon (High LVL)\",EntryWeight=0.05,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Daeodon/Daeodon_Character_BP.Daeodon_Character_BP_C\"),NPCDifficultyLevelRanges=((EnemyLevelsMin=(20),EnemyLevelsMax=(30.999999),GameDifficulties=(0))),NPCsSpawnOffsets=((X=600,Y=0,Z=0)),ColorSets=\"DinoColorSet_AllColors_C\"),(AnEntryName=\"Yutyrannus (High LVL)\",EntryWeight=0.05,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Yutyrannus/Yutyrannus_Character_BP.Yutyrannus_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Carno/Carno_Character_BP.Carno_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Carno/Carno_Character_BP.Carno_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Carno/Carno_Character_BP.Carno_Character_BP_C\"),NPCDifficultyLevelRanges=((EnemyLevelsMin=(20),EnemyLevelsMax=(30.999999),GameDifficulties=(0)),(EnemyLevelsMin=(30),EnemyLevelsMax=(30.999999),GameDifficulties=(0)),(EnemyLevelsMin=(30),EnemyLevelsMax=(30.999999),GameDifficulties=(0)),(EnemyLevelsMin=(30),EnemyLevelsMax=(30.999999),GameDifficulties=(0))),NPCsSpawnOffsets=((X=600,Y=0,Z=0),(X=300,Y=0,Z=0),(X=-300,Y=0,Z=0),(X=0,Y=200,Z=0)),NPCsToSpawnPercentageChance=(1,1,1,1),ColorSets=\"DinoColorSet_AllColors_C\"),(AnEntryName=\"Pengus (3-5)\",EntryWeight=0.2,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Kairuku/Kairuku_Character_BP.Kairuku_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Kairuku/Kairuku_Character_BP.Kairuku_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Kairuku/Kairuku_Character_BP.Kairuku_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Kairuku/Kairuku_Character_BP.Kairuku_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Kairuku/Kairuku_Character_BP.Kairuku_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=-300,Z=0),(X=0,Y=300,Z=0),(X=-300,Y=0,Z=0),(X=300,Y=0,Z=0),(X=0.0,Y=0.0,Z=0.0)),NPCsToSpawnPercentageChance=(0.4,0.6,1,1,1))),NPCSpawnLimits=((NPCClass=\"/Game/PrimalEarth/Dinos/Kairuku/Kairuku_Character_BP.Kairuku_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.3)))", + "ConfigOverrideNPCSpawnEntriesContainer": "(NPCSpawnEntriesContainerClassString=\"DinoSpawnEntries_SwampWater_C\",NPCSpawnEntries=((AnEntryName=\"Deinosuchus (1)\",EntryWeight=0.08,NPCsToSpawn=(\"/Game/ASA/Dinos/Deinosuchus/DeinosuchusASA_Character_BP.DeinosuchusASA_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35)),(AnEntryName=\"Leech One To Four\",EntryWeight=0.125,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Leech/Leech_Character.Leech_Character_C\",\"/Game/PrimalEarth/Dinos/Leech/Leech_Character.Leech_Character_C\",\"/Game/PrimalEarth/Dinos/Leech/Leech_Character.Leech_Character_C\",\"/Game/PrimalEarth/Dinos/Leech/Leech_Character_Diseased.Leech_Character_Diseased_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0),(X=0,Y=250,Z=0),(X=0,Y=-250,Z=0),(X=-250,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1,0.85,0.65,0.09),ManualSpawnPointSpreadRadius=650,SpawnMinDistanceFromStructuresMultiplier=0.4,SpawnMinDistanceFromPlayersMultiplier=0.3,SpawnMinDistanceFromTamedDinosMultiplier=0.4,GroupSpawnOffset=(X=0,Y=0,Z=0)),(AnEntryName=\"Fish One To Four\",EntryWeight=0.24,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Coelacanth/Coel_Character_BP.Coel_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Coelacanth/Coel_Character_BP.Coel_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Coelacanth/Coel_Character_BP.Coel_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Coelacanth/Coel_Character_BP.Coel_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0),(X=0,Y=250,Z=0),(X=0,Y=-250,Z=0),(X=-250,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1,1,0.7,0.4),ManualSpawnPointSpreadRadius=650,WaterOnlySpawnMinimumWaterHeight=20,SpawnMinDistanceFromStructuresMultiplier=0.3,SpawnMinDistanceFromPlayersMultiplier=0.2,SpawnMinDistanceFromTamedDinosMultiplier=0.3,GroupSpawnOffset=(X=0,Y=0,Z=0)),(AnEntryName=\"Piranha Two to Four\",EntryWeight=0.28,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Piranha/Piranha_Character_BP.Piranha_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Piranha/Piranha_Character_BP.Piranha_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Piranha/Piranha_Character_BP.Piranha_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Piranha/Piranha_Character_BP.Piranha_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0),(X=0,Y=250,Z=0),(X=0,Y=-250,Z=0),(X=-250,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1,1,0.75,0.375),ManualSpawnPointSpreadRadius=650,WaterOnlySpawnMinimumWaterHeight=20,SpawnMinDistanceFromStructuresMultiplier=0.3,SpawnMinDistanceFromPlayersMultiplier=0.2,SpawnMinDistanceFromTamedDinosMultiplier=0.3,GroupSpawnOffset=(X=0,Y=0,Z=0)),(AnEntryName=\"Toad (1)\",EntryWeight=0.2,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Toad/Toad_Character_BP.Toad_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35)),(AnEntryName=\"Sarco (1)\",EntryWeight=0.1,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Sarco/Sarco_Character_BP.Sarco_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35)),(AnEntryName=\"Kapro (1-2)\",EntryWeight=0.08,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Kaprosuchus/Kaprosuchus_Character_BP.Kaprosuchus_Character_BP_C\",\"/Game/PrimalEarth/Dinos/Kaprosuchus/Kaprosuchus_Character_BP.Kaprosuchus_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0),(X=0,Y=-220,Z=0)),NPCsToSpawnPercentageChance=(1,0.45),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35)),(AnEntryName=\"Dimetro (1)\",EntryWeight=0.09,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Dimetrodon/Dimetro_Character_BP.Dimetro_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35)),(AnEntryName=\"Diplo (1)\",EntryWeight=0.09,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Diplocaulus/Diplocaulus_Character_BP.Diplocaulus_Character_BP_C\"),NPCsSpawnOffsets=((X=0,Y=0,Z=0)),NPCsToSpawnPercentageChance=(1),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35)),(AnEntryName=\"Bary (1)\",EntryWeight=0.06,NPCsToSpawn=(\"/Game/PrimalEarth/Dinos/Baryonyx/Baryonyx_Character_BP.Baryonyx_Character_BP_C\"),NPCDifficultyLevelRanges=((EnemyLevelsMin=(16),EnemyLevelsMax=(30.999999),GameDifficulties=(0))),NPCsToSpawnPercentageChance=(1),ManualSpawnPointSpreadRadius=650,GroupSpawnOffset=(X=0,Y=0,Z=35))),NPCSpawnLimits=((NPCClass=\"/Game/PrimalEarth/Dinos/Leech/Leech_Character.Leech_Character_C\",MaxPercentageOfDesiredNumToAllow=0.18),(NPCClass=\"/Game/PrimalEarth/Dinos/Coelacanth/Coel_Character_BP.Coel_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.35),(NPCClass=\"/Game/PrimalEarth/Dinos/Piranha/Piranha_Character_BP.Piranha_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.35),(NPCClass=\"/Game/PrimalEarth/Dinos/Toad/Toad_Character_BP.Toad_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.2),(NPCClass=\"/Game/PrimalEarth/Dinos/Sarco/Sarco_Character_BP.Sarco_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.1),(NPCClass=\"/Game/PrimalEarth/Dinos/Dimetrodon/Dimetro_Character_BP.Dimetro_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.1),(NPCClass=\"/Game/PrimalEarth/Dinos/Kaprosuchus/Kaprosuchus_Character_BP.Kaprosuchus_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.06),(NPCClass=\"/Game/PrimalEarth/Dinos/Diplocaulus/Diplocaulus_Character_BP.Diplocaulus_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.08),(NPCClass=\"/Game/PrimalEarth/Dinos/Baryonyx/Baryonyx_Character_BP.Baryonyx_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.06),(NPCClass=\"/Game/ASA/Dinos/Deinosuchus/DeinosuchusASA_Character_BP.DeinosuchusASA_Character_BP_C\",MaxPercentageOfDesiredNumToAllow=0.1)))", + "ConfigOverrideSupplyCrateItems": "(SupplyCrateClassString=\"SupplyCreate_OceanInstant_High_SE_C\",MinItemSets=4,MaxItemSets=6,NumItemSetsPower=1.0,bSetsRandomWithoutReplacement=False,ItemSets=((SetName=\"Cave Weapons - Tier 2 2\",MinNumItems=2,MaxNumItems=2,NumItemsPower=1,SetWeight=500,bItemsRandomWithoutReplacement=False,ItemEntries=((EntryWeight=500,ItemClassStrings=(\"PrimalItem_WeaponMachinedShotgun_C\",\"PrimalItem_WeaponRifle_C\",\"PrimalItem_WeaponCompoundBow_C\",\"PrimalItem_WeaponProd_C\",\"PrimalItem_WeaponMachinedSniper_C\"),ItemsWeights=(500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0.5,ItemStatClampsMultiplier=0),(EntryWeight=150,ItemClassStrings=(\"PrimalItem_WeaponRocketLauncher_C\",\"PrimalItem_WeaponC4_C\",\"PrimalItemC4Ammo_C\",\"PrimalItemAmmo_Rocket_C\"),ItemsWeights=(500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0,ItemStatClampsMultiplier=0),(EntryWeight=500,ItemClassStrings=(\"PrimalItemAmmo_SimpleShotgunBullet_C\",\"PrimalItemAmmo_AdvancedRifleBullet_C\",\"PrimalItemAmmo_AdvancedSniperBullet_C\",\"PrimalItemAmmo_CompoundBowArrow_C\"),ItemsWeights=(500000,500000,500000,500000),MinQuantity=4,MaxQuantity=20,MinQuality=0,MaxQuality=8.064516129,bApplyQuantityToSingleItem=True,bForceBlueprint=False,ChanceToBeBlueprintOverride=0,ItemStatClampsMultiplier=0))),(SetName=\"Ice Cave Saddles\",MinNumItems=2,MaxNumItems=2,NumItemsPower=1,SetWeight=500,bItemsRandomWithoutReplacement=False,ItemEntries=((EntryWeight=500,ItemClassStrings=(\"PrimalItemArmor_YutySaddle_C\",\"PrimalItemArmor_QuetzSaddle_C\",\"PrimalItemArmor_QuetzSaddle_Platform_C\",\"PrimalItemArmor_TherizinosaurusSaddle_C\",\"PrimalItemArmor_CarchaSaddle_C\",\"PrimalItemArmor_DaeodonSaddle_C\",\"PrimalItemArmor_GigantoraptorSaddle_C\"),ItemsWeights=(500000,500000,500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0.5,ItemStatClampsMultiplier=0),(EntryWeight=350,ItemClassStrings=(\"PrimalItemConsumable_CookedMeat_Jerky_C\",\"PrimalItemConsumable_CookedPrimeMeat_Fish_C\",\"PrimalItemConsumable_CookedPrimeMeat_Jerky_C\",\"PrimalItemConsumable_Soup_EnduroStew_C\",\"PrimalItemConsumable_Soup_LazarusChowder_C\",\"PrimalItemConsumable_Soup_ShadowSteak_C\"),ItemsWeights=(500000,500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=2,MinQuality=0,MaxQuality=8.064516129,bApplyQuantityToSingleItem=True,bForceBlueprint=False,ChanceToBeBlueprintOverride=0,ItemStatClampsMultiplier=0))),(SetName=\"ice Cave Saddles - Tier 3\",MinNumItems=2,MaxNumItems=2,NumItemsPower=1,SetWeight=500,bItemsRandomWithoutReplacement=False,ItemEntries=((EntryWeight=500,ItemClassStrings=(\"PrimalItemArmor_GigantSaddle_C\",\"PrimalItemArmor_RexSaddle_C\",\"PrimalItemArmor_SauroSaddle_C\",\"PrimalItemArmor_SauroSaddle_Platform_C\",\"PrimalItemArmor_RhynioSaddle_C\"),ItemsWeights=(500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0.5,ItemStatClampsMultiplier=0),(EntryWeight=350,ItemClassStrings=(\"PrimalItemConsumable_CookedMeat_Jerky_C\",\"PrimalItemConsumable_CookedPrimeMeat_Fish_C\",\"PrimalItemConsumable_CookedPrimeMeat_Jerky_C\",\"PrimalItemConsumable_Soup_EnduroStew_C\",\"PrimalItemConsumable_Soup_LazarusChowder_C\",\"PrimalItemConsumable_Soup_ShadowSteak_C\"),ItemsWeights=(500000,500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=2,MinQuality=0,MaxQuality=8.064516129,bApplyQuantityToSingleItem=True,bForceBlueprint=False,ChanceToBeBlueprintOverride=0,ItemStatClampsMultiplier=0))),(SetName=\"Ocean Drops\",MinNumItems=1,MaxNumItems=1,NumItemsPower=1,SetWeight=500,bItemsRandomWithoutReplacement=False,ItemEntries=((EntryWeight=500,ItemClassStrings=(\"PrimalItem_WeaponTorch_C\",\"PrimalItem_WeaponStoneClub_C\",\"PrimalItem_WeaponSlingshot_C\",\"PrimalItem_WeaponBow_C\",\"PrimalItem_WeaponLance_C\",\"PrimalItem_WeaponMetalHatchet_C\",\"PrimalItem_WeaponMetalPick_C\",\"PrimalItem_WeaponSickle_C\",\"PrimalItem_WeaponSword_C\",\"PrimalItem_WeaponPike_C\",\"PrimalItem_WeaponGun_C\",\"PrimalItem_WeaponCrossbow_C\",\"PrimalItem_WeaponShotgun_C\",\"PrimalItem_WeaponOneShotRifle_C\",\"PrimalItem_WeaponMachinedPistol_C\",\"PrimalItem_WeaponHarpoon_C\",\"PrimalItem_WeaponMachinedShotgun_C\",\"PrimalItem_WeaponRifle_C\",\"PrimalItem_WeaponCompoundBow_C\",\"PrimalItem_WeaponProd_C\",\"PrimalItem_WeaponMachinedSniper_C\"),ItemsWeights=(500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0.5,ItemStatClampsMultiplier=0),(EntryWeight=500,ItemClassStrings=(\"PrimalItemArmor_ClothBoots_C\",\"PrimalItemArmor_ClothGloves_C\",\"PrimalItemArmor_ClothHelmet_C\",\"PrimalItemArmor_ClothPants_C\",\"PrimalItemArmor_ClothShirt_C\",\"PrimalItemArmor_HideShirt_C\",\"PrimalItemArmor_HidePants_C\",\"PrimalItemArmor_HideHelmet_C\",\"PrimalItemArmor_HideGloves_C\",\"PrimalItemArmor_HideBoots_C\",\"PrimalItemArmor_WoodShield_C\",\"PrimalItemArmor_ChitinShirt_C\",\"PrimalItemArmor_ChitinPants_C\",\"PrimalItemArmor_ChitinHelmet_C\",\"PrimalItemArmor_ChitinGloves_C\",\"PrimalItemArmor_ChitinBoots_C\",\"PrimalItemArmor_FurShirt_C\",\"PrimalItemArmor_FurPants_C\",\"PrimalItemArmor_FurHelmet_C\",\"PrimalItemArmor_FurGloves_C\",\"PrimalItemArmor_FurBoots_C\",\"PrimalItemArmor_GhillieShirt_C\",\"PrimalItemArmor_GhilliePants_C\",\"PrimalItemArmor_GhillieHelmet_C\",\"PrimalItemArmor_GhillieGloves_C\",\"PrimalItemArmor_GhillieBoots_C\",\"PrimalItemArmor_ScubaShirt_SuitWithTank_C\",\"PrimalItemArmor_ScubaPants_C\",\"PrimalItemArmor_ScubaHelmet_Goggles_C\",\"PrimalItemArmor_ScubaBoots_Flippers_C\",\"PrimalItemArmor_MetalShirt_C\",\"PrimalItemArmor_MetalShield_C\",\"PrimalItemArmor_MetalPants_C\",\"PrimalItemArmor_MetalHelmet_C\",\"PrimalItemArmor_MetalGloves_C\",\"PrimalItemArmor_MetalBoots_C\",\"PrimalItemArmor_MinersHelmet_C\"),ItemsWeights=(500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0.5,ItemStatClampsMultiplier=0),(EntryWeight=500,ItemClassStrings=(\"PrimalItemArmor_YutySaddle_C\",\"PrimalItemArmor_TusoSaddle_C\",\"PrimalItemArmor_TurtleSaddle_C\",\"PrimalItemArmor_TrikeSaddle_C\",\"PrimalItemArmor_ToadSaddle_C\",\"PrimalItemArmor_ThylacoSaddle_C\",\"PrimalItemArmor_TherizinosaurusSaddle_C\",\"PrimalItemArmor_TerrorBirdSaddle_C\",\"PrimalItemArmor_TapejaraSaddle_C\",\"PrimalItemArmor_StegoSaddle_C\",\"PrimalItemArmor_StagSaddle_C\",\"PrimalItemArmor_SpinoSaddle_C\",\"PrimalItemArmor_SpiderSaddle_C\",\"PrimalItemArmor_ScorpionSaddle_C\",\"PrimalItemArmor_SauroSaddle_Platform_C\",\"PrimalItemArmor_SauroSaddle_C\",\"PrimalItemArmor_SarcoSaddle_C\",\"PrimalItemArmor_SaberSaddle_C\",\"PrimalItemArmor_RhynioSaddle_C\",\"PrimalItemArmor_RhinoSaddle_C\",\"PrimalItemArmor_RexSaddle_C\",\"PrimalItemArmor_RaptorSaddle_C\",\"PrimalItemArmor_QuetzSaddle_C\",\"PrimalItemArmor_PteroSaddle_C\",\"PrimalItemArmor_ProcoptodonSaddle_C\",\"PrimalItemArmor_PlesiSaddle_Platform_C\",\"PrimalItemArmor_PlesiaSaddle_C\",\"PrimalItemArmor_PhiomiaSaddle_C\",\"PrimalItemArmor_PelaSaddle_C\",\"PrimalItemArmor_ParaSaddle_C\",\"PrimalItemArmor_ParacerSaddle_Platform_C\",\"PrimalItemArmor_Paracer_Saddle_C\",\"PrimalItemArmor_PachySaddle_C\",\"PrimalItemArmor_PachyrhinoSaddle_C\",\"PrimalItemArmor_MosaSaddle_Platform_C\",\"PrimalItemArmor_MosaSaddle_C\",\"PrimalItemArmor_MegatheriumSaddle_C\",\"PrimalItemArmor_MegalosaurusSaddle_C\",\"PrimalItemArmor_MegalodonSaddle_C\",\"PrimalItemArmor_MegalaniaSaddle_C\",\"PrimalItemArmor_MantaSaddle_C\",\"PrimalItemArmor_MammothSaddle_C\",\"PrimalItemArmor_KaprosuchusSaddle_C\",\"PrimalItemArmor_IguanodonSaddle_C\",\"PrimalItemArmor_HyaenodonSaddle_C\",\"PrimalItemArmor_GigantSaddle_C\",\"PrimalItemArmor_Gallimimus_C\",\"PrimalItemArmor_EquusSaddle_C\",\"PrimalItemArmor_DunkleosteusSaddle_C\",\"PrimalItemArmor_DolphinSaddle_C\",\"PrimalItemArmor_DoedSaddle_C\",\"PrimalItemArmor_DireBearSaddle_C\",\"PrimalItemArmor_DiplodocusSaddle_C\",\"PrimalItemArmor_DaeodonSaddle_C\",\"PrimalItemArmor_ChalicoSaddle_C\",\"PrimalItemArmor_CarnoSaddle_C\",\"PrimalItemArmor_CarchaSaddle_C\",\"PrimalItemArmor_BeaverSaddle_C\",\"PrimalItemArmor_BasiloSaddle_C\",\"PrimalItemArmor_BaryonyxSaddle_C\",\"PrimalItemArmor_ArthroSaddle_C\",\"PrimalItemArmor_ArgentavisSaddle_C\",\"PrimalItemArmor_AnkyloSaddle_C\",\"PrimalItemArmor_AlloSaddle_C\",\"PrimalItemArmor_GigantoraptorSaddle_C\",\"PrimalItemArmor_CeratosaurusSaddle_ASA_C\",\"PrimalItemArmor_XiphSaddle_ASA_C\"),ItemsWeights=(500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000,500000),MinQuantity=1,MaxQuantity=1,MinQuality=0,MaxQuality=8.064516129,bForceBlueprint=False,ChanceToBeBlueprintOverride=0.5,ItemStatClampsMultiplier=0)))))", + "CraftingSkillBonusMultiplier": "1", + "CropDecaySpeedMultiplier": "1", + "CropGrowthSpeedMultiplier": "8", + "CustomRecipeSkillMultiplier": "1", + "DinoHarvestingDamageMultiplier": "4.30000019", + "ExplorerNoteXPMultiplier": "0.5", + "FastDecayInterval": "43200", + "FishingLootQualityMultiplier": "0.699999988", + "GlobalCorpseDecompositionTimeMultiplier": "16", + "GlobalItemDecompositionTimeMultiplier": "5", + "GlobalSpoilingTimeMultiplier": "1", + "HarvestXPMultiplier": "2", + "ItemStatClamps[1]": "30000", + "ItemStatClamps[3]": "20000", + "LayEggIntervalMultiplier": "0.5", + "MatingIntervalMultiplier": "0.000899999985", + "MatingSpeedMultiplier": "10", + "PassiveTameIntervalMultiplier": "0.5", + "PerLevelStatsMultiplier_DinoTamed[9]": "0.25", + "PerLevelStatsMultiplier_Player[9]": "0.25", + "PlayerHarvestingDamageMultiplier": "1", + "ResourceNoReplenishRadiusPlayers": "1", + "SupplyCrateLootQualityMultiplier": "0.180000007", + "TamedDinoTorporDrainMultiplier": "2", + "TamedKillXPMultiplier": "2", + "UnclaimedKillXPMultiplier": "4", + "UseCorpseLifeSpanMultiplier": "8", + "PerLevelStatsMultiplier_DinoTamed[1]": "1", + "PerLevelStatsMultiplier_DinoTamed[2]": "1", + "PerLevelStatsMultiplier_DinoTamed[3]": "1", + "PerLevelStatsMultiplier_DinoTamed[4]": "1", + "PerLevelStatsMultiplier_DinoTamed[5]": "1", + "PerLevelStatsMultiplier_DinoTamed[6]": "1", + "PerLevelStatsMultiplier_DinoTamed[7]": "1", + "PerLevelStatsMultiplier_DinoTamed[10]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[1]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[2]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[3]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[4]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[5]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[6]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[7]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[9]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[10]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[0]": "0.439999998", + "PerLevelStatsMultiplier_DinoTamed_Affinity[1]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[2]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[3]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[4]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[5]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[6]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[7]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[8]": "0.439999998", + "PerLevelStatsMultiplier_DinoTamed_Affinity[9]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[10]": "1", + "PerLevelStatsMultiplier_DinoWild[0]": "1", + "PerLevelStatsMultiplier_DinoWild[1]": "1", + "PerLevelStatsMultiplier_DinoWild[2]": "1", + "PerLevelStatsMultiplier_DinoWild[3]": "1", + "PerLevelStatsMultiplier_DinoWild[4]": "1", + "PerLevelStatsMultiplier_DinoWild[5]": "1", + "PerLevelStatsMultiplier_DinoWild[6]": "1", + "PerLevelStatsMultiplier_DinoWild[7]": "1", + "PerLevelStatsMultiplier_DinoWild[8]": "1", + "PerLevelStatsMultiplier_DinoWild[9]": "1", + "PerLevelStatsMultiplier_DinoWild[10]": "1", + "PerLevelStatsMultiplier_Player[0]": "1", + "PerLevelStatsMultiplier_Player[1]": "1", + "PerLevelStatsMultiplier_Player[2]": "1", + "PerLevelStatsMultiplier_Player[3]": "1", + "PerLevelStatsMultiplier_Player[4]": "1", + "PerLevelStatsMultiplier_Player[5]": "1", + "PerLevelStatsMultiplier_Player[6]": "1", + "PerLevelStatsMultiplier_Player[7]": "1", + "PerLevelStatsMultiplier_Player[8]": "1", + "PerLevelStatsMultiplier_Player[10]": "1", + "PvPZoneStructureDamageMultiplier": "6", + "StructureDamageRepairCooldown": "180", + "IncreasePvPRespawnIntervalCheckPeriod": "300", + "IncreasePvPRespawnIntervalMultiplier": "2", + "ResourceNoReplenishRadiusStructures": "1", + "PoopIntervalMultiplier": "1", + "DinoTurretDamageMultiplier": "1", + "CustomRecipeEffectivenessMultiplier": "1", + "KillXPMultiplier": "1", + "CraftXPMultiplier": "1", + "GenericXPMultiplier": "1", + "SpecialXPMultiplier": "1", + "AlphaKillXPMultiplier": "1", + "WildKillXPMultiplier": "1", + "CaveKillXPMultiplier": "1", + "bPvEAllowTribeWar": "False", + "bUseSingleplayerSettings": "False", + "bAllowSpeedLeveling": "False", + "bAllowFlyerSpeedLeveling": "False" + }, + "ShooterGameMode_TEMPOverrides": { + "bAllowFlyerSpeedLeveling": "False", + "bAllowSpeedLeveling": "False", + "bDisableStructurePlacementCollision": "True", + "bUseSingleplayerSettings": "False", + "bPvEAllowTribeWarCancel": "True", + "bPvEAllowTribeWar": "False", + "bPassiveDefensesDamageRiderlessDinos": "True", + "bFlyerPlatformAllowUnalignedDinoBasing": "True", + "CraftingSkillBonusMultiplier": "1", + "FishingLootQualityMultiplier": "0.699999988", + "SupplyCrateLootQualityMultiplier": "0.180000007", + "UnclaimedKillXPMultiplier": "4", + "TamedKillXPMultiplier": "2", + "CaveKillXPMultiplier": "1", + "WildKillXPMultiplier": "1", + "AlphaKillXPMultiplier": "1", + "BossKillXPMultiplier": "2", + "ExplorerNoteXPMultiplier": "0.5", + "SpecialXPMultiplier": "1", + "GenericXPMultiplier": "1", + "CraftXPMultiplier": "1", + "HarvestXPMultiplier": "2", + "KillXPMultiplier": "1", + "BabyCuddleLoseImprintQualitySpeedMultiplier": "1", + "BabyCuddleGracePeriodMultiplier": "1", + "BabyCuddleIntervalMultiplier": "0.0184000004", + "BabyImprintingStatScaleMultiplier": "1", + "CustomRecipeSkillMultiplier": "1", + "CustomRecipeEffectivenessMultiplier": "1", + "PlayerHarvestingDamageMultiplier": "1", + "DinoHarvestingDamageMultiplier": "4.30000019", + "DinoTurretDamageMultiplier": "1", + "BabyFoodConsumptionSpeedMultiplier": "1", + "BabyMatureSpeedMultiplier": "244", + "MatingIntervalMultiplier": "0.000899999985", + "CropDecaySpeedMultiplier": "1", + "PoopIntervalMultiplier": "1", + "LayEggIntervalMultiplier": "0.5", + "CropGrowthSpeedMultiplier": "8", + "ResourceNoReplenishRadiusStructures": "1", + "ResourceNoReplenishRadiusPlayers": "1", + "IncreasePvPRespawnIntervalMultiplier": "2", + "IncreasePvPRespawnIntervalCheckPeriod": "300", + "StructureDamageRepairCooldown": "180", + "PvPZoneStructureDamageMultiplier": "6", + "GlobalCorpseDecompositionTimeMultiplier": "16", + "GlobalItemDecompositionTimeMultiplier": "5", + "GlobalSpoilingTimeMultiplier": "1", + "PerLevelStatsMultiplier_Player[10]": "1", + "PerLevelStatsMultiplier_Player[9]": "0.25", + "PerLevelStatsMultiplier_Player[8]": "1", + "PerLevelStatsMultiplier_Player[7]": "1", + "PerLevelStatsMultiplier_Player[6]": "1", + "PerLevelStatsMultiplier_Player[5]": "1", + "PerLevelStatsMultiplier_Player[4]": "1", + "PerLevelStatsMultiplier_Player[3]": "1", + "PerLevelStatsMultiplier_Player[2]": "1", + "PerLevelStatsMultiplier_Player[1]": "1", + "PerLevelStatsMultiplier_Player[0]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[8]": "0.439999998", + "PerLevelStatsMultiplier_DinoTamed_Affinity[9]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[7]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[6]": "1", + "PerLevelStatsMultiplier_DinoWild[10]": "1", + "PerLevelStatsMultiplier_DinoWild[9]": "1", + "PerLevelStatsMultiplier_DinoWild[8]": "1", + "PerLevelStatsMultiplier_DinoWild[7]": "1", + "PerLevelStatsMultiplier_DinoWild[6]": "1", + "PerLevelStatsMultiplier_DinoWild[5]": "1", + "PerLevelStatsMultiplier_DinoWild[4]": "1", + "PerLevelStatsMultiplier_DinoWild[3]": "1", + "PerLevelStatsMultiplier_DinoWild[2]": "1", + "PerLevelStatsMultiplier_DinoWild[1]": "1", + "PerLevelStatsMultiplier_DinoWild[0]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[10]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[1]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[5]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[4]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[3]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[2]": "1", + "PerLevelStatsMultiplier_DinoTamed_Affinity[0]": "0.439999998", + "PerLevelStatsMultiplier_DinoTamed_Add[10]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[9]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[7]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[6]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[5]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[4]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[3]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[2]": "1", + "PerLevelStatsMultiplier_DinoTamed[3]": "1", + "PerLevelStatsMultiplier_DinoTamed_Add[1]": "1", + "PerLevelStatsMultiplier_DinoTamed[10]": "1", + "PerLevelStatsMultiplier_DinoTamed[9]": "0.25", + "PerLevelStatsMultiplier_DinoTamed[7]": "1", + "PerLevelStatsMultiplier_DinoTamed[5]": "1", + "PerLevelStatsMultiplier_DinoTamed[4]": "1", + "PerLevelStatsMultiplier_DinoTamed[6]": "1", + "PerLevelStatsMultiplier_DinoTamed[2]": "1", + "PerLevelStatsMultiplier_DinoTamed[1]": "1" + } + } + } +} \ No newline at end of file diff --git a/stapler-scripts/ark-mod-manager/uv.lock b/stapler-scripts/ark-mod-manager/uv.lock new file mode 100644 index 0000000..90a4463 --- /dev/null +++ b/stapler-scripts/ark-mod-manager/uv.lock @@ -0,0 +1,353 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[manifest] +members = [ + "ark-mod-manager", + "experiments", +] + +[[package]] +name = "ark-mod-manager" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.14.3" }, + { name = "requests", specifier = ">=2.32.5" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, +] + +[[package]] +name = "experiments" +version = "0.1.0" +source = { virtual = "experiments" } +dependencies = [ + { name = "cryptography" }, + { name = "lz4" }, + { name = "pyuepak" }, + { name = "zstandard" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=46.0.4" }, + { name = "lz4", specifier = ">=4.4.5" }, + { name = "pyuepak", specifier = ">=0.2.6" }, + { name = "zstandard", specifier = ">=0.25.0" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pyuepak" +version = "0.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/32/607ad59ceccbad5b01daa44961fe9272a5d5f424154795454e84f7c1c44b/pyuepak-0.2.6.tar.gz", hash = "sha256:7aa253229fc642fa8805d7beca9e31844f0a02d39c7b36df6707dbcfcd1f80fe", size = 18664, upload-time = "2026-01-23T11:21:15.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/ff/98b6acbd06eeae491fa44453719f6aee0da5d4a055b1da639728f8d63499/pyuepak-0.2.6-py3-none-any.whl", hash = "sha256:c6ad466fca86bf7cd25fe82e2cc51a0649a5a8df081cf84de6e3da57a35d9fdc", size = 18774, upload-time = "2026-01-23T11:21:14.574Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/stapler-scripts/claude-proxy/providers/bedrock.py b/stapler-scripts/claude-proxy/providers/bedrock.py index 4792965..833b231 100644 --- a/stapler-scripts/claude-proxy/providers/bedrock.py +++ b/stapler-scripts/claude-proxy/providers/bedrock.py @@ -1,5 +1,6 @@ """AWS Bedrock provider implementation.""" import json +import anyio import boto3 from typing import Dict, Any, AsyncIterator, Optional from . import Provider, RateLimitError, ValidationError @@ -86,16 +87,19 @@ async def send_message( bedrock_body.pop("model", None) try: - # Synchronous call wrapped in async - response = self.client.invoke_model( - modelId=bedrock_model, - contentType="application/json", - accept="application/json", - body=json.dumps(bedrock_body) + # Synchronous call wrapped in async using thread pool + response = await anyio.to_thread.run_sync( + lambda: self.client.invoke_model( + modelId=bedrock_model, + contentType="application/json", + accept="application/json", + body=json.dumps(bedrock_body) + ) ) - # Parse response - result = json.loads(response["body"].read()) + # Parse response - reading from the body is also blocking I/O + body_content = await anyio.to_thread.run_sync(response["body"].read) + result = json.loads(body_content) return self._convert_response(result, original_model) except self.client.exceptions.ThrottlingException: @@ -126,16 +130,23 @@ async def stream_message( bedrock_body.pop("model", None) try: - # Invoke with streaming - response = self.client.invoke_model_with_response_stream( - modelId=bedrock_model, - contentType="application/json", - accept="application/json", - body=json.dumps(bedrock_body) + # Invoke with streaming wrapped in async using thread pool + response = await anyio.to_thread.run_sync( + lambda: self.client.invoke_model_with_response_stream( + modelId=bedrock_model, + contentType="application/json", + accept="application/json", + body=json.dumps(bedrock_body) + ) ) - # Stream events - for event in response["body"]: + # Stream events - the EventStream is a synchronous iterator, so we wrap next() in a thread + iterator = iter(response["body"]) + while True: + event = await anyio.to_thread.run_sync(next, iterator, None) + if event is None: + break + chunk = json.loads(event["chunk"]["bytes"]) # Convert to SSE format matching Anthropic diff --git a/stapler-scripts/claude-proxy/requirements.txt b/stapler-scripts/claude-proxy/requirements.txt index 670bfba..b7e270a 100644 --- a/stapler-scripts/claude-proxy/requirements.txt +++ b/stapler-scripts/claude-proxy/requirements.txt @@ -1,5 +1,6 @@ fastapi==0.115.5 uvicorn[standard]==0.32.1 httpx==0.27.2 +anyio>=4.0.0 boto3==1.35.78 pydantic==2.10.3 \ No newline at end of file diff --git a/stapler-scripts/display-switch/.python-version b/stapler-scripts/display-switch/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/stapler-scripts/display-switch/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/stapler-scripts/display-switch/README.md b/stapler-scripts/display-switch/README.md new file mode 100644 index 0000000..e69de29 diff --git a/stapler-scripts/display-switch/display_switch.py b/stapler-scripts/display-switch/display_switch.py new file mode 100755 index 0000000..e655d38 --- /dev/null +++ b/stapler-scripts/display-switch/display_switch.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +import sys +import subprocess +import re +import os +import stat +import datetime + +STATE_FILE = "/tmp/monitor_state.sh" +LOG_FILE = "/tmp/display_switch.log" + +def log(message): + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, "a") as f: + f.write(f"[{timestamp}] {message}\n") + # Also print to stdout/stderr for Sunshine logs + print(message) + +def get_current_state(): + """Parses xrandr output to get current state of connected monitors.""" + try: + # Ensure we are capturing output + output = subprocess.check_output(["xrandr", "--verbose"], text=True) + except subprocess.CalledProcessError as e: + log(f"Error running xrandr: {e}") + sys.exit(1) + except FileNotFoundError: + log("Error: xrandr command not found.") + sys.exit(1) + + monitors = [] + current_monitor = None + + # Matches: Name, "connected", optional "primary", geometry/pos string, rest + # Example: "HDMI-0 connected 1920x1080+3146+0 (0x1cc) normal ..." + # Group 1: Name (HDMI-0) + # Group 2: "primary " or None + # Group 3: Geometry (1920x1080+3146+0) + # Group 4: Identifier (0x1cc) - ignored by non-capturing group logic if not strictly matched? + # Actually, let's make it more robust. + + for line in output.splitlines(): + line = line.strip() + if not line: continue + + # Detection line + if " connected" in line: + # Save previous + if current_monitor: + monitors.append(current_monitor) + + parts = line.split() + name = parts[0] + state = parts[1] # "connected" + + is_primary = "primary" in parts + + # Find geometry part: looks like WxH+X+Y + geom_index = -1 + geom_pos = None + for i, part in enumerate(parts): + if re.match(r"[0-9]+x[0-9]+\+[0-9]+\+[0-9]+", part): + geom_pos = part + geom_index = i + break + + # Rotation is typically the token AFTER geometry (and optional identifier) + # But BEFORE the parentheses starting capabilities like (normal left ...) + # Example: ... 1920x1080+0+0 (0x1cc) normal (normal ... + # Example: ... 1440x2560+0+0 right (normal ... + + rotation = "normal" + if geom_index != -1: + # Scan tokens after geometry + for i in range(geom_index + 1, len(parts)): + token = parts[i] + # Skip identifier like (0x1e4) + if token.startswith("(0x"): + continue + + if token in ["normal", "left", "right", "inverted"]: + rotation = token + break + if token.startswith("("): + # Hit the capabilities list, stop searching + break + + current_monitor = { + "name": name, + "primary": is_primary, + "active": bool(geom_pos), + "pos": None, + "rotation": rotation, + "active_mode": None, + "rate": None + } + + if geom_pos: + # Extract mode and pos from string like 1920x1080+3146+0 + # mode: 1920x1080 + # pos: +3146+0 -> convert to 3146x0 + m = re.match(r"([0-9]+x[0-9]+)(\+[0-9]+\+[0-9]+)", geom_pos) + if m: + current_monitor['active_mode'] = m.group(1) + raw_pos = m.group(2) # +3146+0 + # Convert +X+Y to XxY + pm = re.match(r"\+(\d+)\+(\d+)", raw_pos) + if pm: + current_monitor['pos'] = f"{pm.group(1)}x{pm.group(2)}" + + continue + + # Rate line (looking for *) + # 1920x1080 (0x1cc) 60.00*+ 119.88 ... + if current_monitor and current_monitor['active'] and not current_monitor['rate']: + if "*" in line: + # This line contains the active rate + # The first token usually is NOT the rate in verbose output? + # In verbose: " 1920x1080 (0x1cc) 60.00*+ 119.88 ..." -> NO, verbose output is messy. + # Let's fallback to standard xrandr for rate if needed, or parse carefully. + # Standard xrandr: " 1920x1080 60.00*+ 119.88 ..." + # Verbose: " 1920x1080 (0x1cc) 60.00*+ 119.88 ..." + + # Simple strategy: find token with * + tokens = line.split() + for t in tokens: + if "*" in t: + rate = t.strip("*+") + current_monitor['rate'] = rate + break + + if current_monitor: + monitors.append(current_monitor) + + return monitors + +def save_state(): + log("Saving state...") + if os.path.exists(STATE_FILE): + log(f"State file {STATE_FILE} already exists. Skipping save to preserve original state.") + return + + monitors = get_current_state() + active_monitors = [m for m in monitors if m['active']] + + if not active_monitors: + log("No active monitors found to save.") + return + + command_parts = ["xrandr"] + + for m in active_monitors: + command_parts.extend(["--output", m['name']]) + + if m['primary']: + command_parts.append("--primary") + + if m['active_mode']: + command_parts.extend(["--mode", m['active_mode']]) + + if m['rate']: + command_parts.extend(["--rate", m['rate']]) + + if m['pos']: + command_parts.extend(["--pos", m['pos']]) + + command_parts.extend(["--rotate", m['rotation']]) + + full_command = " ".join(command_parts) + log(f"Generated restore command: {full_command}") + + try: + with open(STATE_FILE, "w") as f: + f.write("#!/bin/bash\n") + f.write(full_command + "\n") + + os.chmod(STATE_FILE, os.stat(STATE_FILE).st_mode | stat.S_IEXEC) + log(f"State saved successfully to {STATE_FILE}") + except IOError as e: + log(f"Failed to write state file: {e}") + sys.exit(1) + +def restore_state(): + log("Restoring state...") + if not os.path.exists(STATE_FILE): + log(f"No state file found at {STATE_FILE}. Nothing to restore.") + return + + try: + log(f"Executing {STATE_FILE}...") + subprocess.run(STATE_FILE, check=True, shell=True) + log("State restored successfully.") + os.remove(STATE_FILE) + log(f"Deleted {STATE_FILE}") + except subprocess.CalledProcessError as e: + log(f"Failed to restore state: {e}") + sys.exit(1) + +if __name__ == "__main__": + if len(sys.argv) != 2 or sys.argv[1] not in ["save", "restore"]: + print("Usage: python3 display_switch.py [save|restore]") + sys.exit(1) + + # Ensure DISPLAY is set (heuristic) + if "DISPLAY" not in os.environ: + os.environ["DISPLAY"] = ":0" + log("DISPLAY environment variable was missing, set to :0") + + if sys.argv[1] == "save": + save_state() + else: + restore_state() \ No newline at end of file diff --git a/stapler-scripts/display-switch/main.py b/stapler-scripts/display-switch/main.py new file mode 100644 index 0000000..c8fca1c --- /dev/null +++ b/stapler-scripts/display-switch/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from display-switch!") + + +if __name__ == "__main__": + main() diff --git a/stapler-scripts/display-switch/pyproject.toml b/stapler-scripts/display-switch/pyproject.toml new file mode 100644 index 0000000..e4d194b --- /dev/null +++ b/stapler-scripts/display-switch/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "display-switch" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/stapler-scripts/display-switch/test_display_switch.py b/stapler-scripts/display-switch/test_display_switch.py new file mode 100644 index 0000000..ba65052 --- /dev/null +++ b/stapler-scripts/display-switch/test_display_switch.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import patch, MagicMock +import display_switch + +# Sample xrandr --verbose output +XRANDR_OUTPUT_1 = """ +Screen 0: minimum 8 x 8, current 7440 x 3240, maximum 32767 x 32767 +HDMI-0 connected 1920x1080+3146+0 (0x1cc) normal (normal left inverted right x axis y axis) 620mm x 340mm + Identifier: 0x1bc + Timestamp: 760126363 + Subpixel: unknown + 1920x1080 (0x1cc) 60.00*+ 119.88 75.00 50.00 +DP-0 connected primary 2560x2880+0+360 (0x1e4) left (normal left inverted right x axis y axis) 465mm x 523mm + Identifier: 0x1e3 + 2560x2880 (0x1e4) 59.98*+ +DP-4 connected 1440x2560+6000+680 (0x1f4) right (normal left inverted right x axis y axis) 597mm x 336mm + Identifier: 0x1f3 + 2560x1440 (0x1f4) 143.97*+ 120.00 99.95 59.95 +DP-1 disconnected (normal left inverted right x axis y axis) +""" + +XRANDR_OUTPUT_SIMPLE = """ +Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767 +HDMI-0 connected primary 1920x1080+0+0 (0x46) normal (normal left inverted right x axis y axis) 531mm x 299mm + 1920x1080 (0x46) 60.00*+ 74.97 59.94 50.00 +""" + +@patch('subprocess.check_output') +def test_get_current_state_complex(mock_check_output): + mock_check_output.return_value = XRANDR_OUTPUT_1 + + monitors = display_switch.get_current_state() + + assert len(monitors) == 3 + + # Check HDMI-0 + m1 = monitors[0] + assert m1['name'] == 'HDMI-0' + assert m1['primary'] == False + assert m1['active'] == True + assert m1['rotation'] == 'normal' + assert m1['active_mode'] == '1920x1080' + assert m1['pos'] == '3146x0' + assert m1['rate'] == '60.00' + + # Check DP-0 (Primary, Left rotation) + m2 = monitors[1] + assert m2['name'] == 'DP-0' + assert m2['primary'] == True + assert m2['active'] == True + assert m2['rotation'] == 'left' + assert m2['active_mode'] == '2560x2880' # Note: xrandr reports rotated dims? + # Wait, usually mode line is unrotated dimensions, but geometry string +X+Y is final. + # In regex we extracted 2560x2880 from geometry. + assert m2['pos'] == '0x360' + assert m2['rate'] == '59.98' + + # Check DP-4 (Right rotation) + m3 = monitors[2] + assert m3['name'] == 'DP-4' + assert m3['rotation'] == 'right' + assert m3['active_mode'] == '1440x2560' + assert m3['pos'] == '6000x680' + assert m3['rate'] == '143.97' + +@patch('subprocess.check_output') +def test_get_current_state_simple(mock_check_output): + mock_check_output.return_value = XRANDR_OUTPUT_SIMPLE + + monitors = display_switch.get_current_state() + + assert len(monitors) == 1 + m = monitors[0] + assert m['name'] == 'HDMI-0' + assert m['primary'] == True + assert m['rotation'] == 'normal' + assert m['pos'] == '0x0' + assert m['rate'] == '60.00' diff --git a/stapler-scripts/display-switch/uv.lock b/stapler-scripts/display-switch/uv.lock new file mode 100644 index 0000000..c43af76 --- /dev/null +++ b/stapler-scripts/display-switch/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "display-switch" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] diff --git a/stapler-scripts/llm-sync/.python-version b/stapler-scripts/llm-sync/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/stapler-scripts/llm-sync/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/stapler-scripts/llm-sync/main.py b/stapler-scripts/llm-sync/main.py new file mode 100644 index 0000000..0371bbe --- /dev/null +++ b/stapler-scripts/llm-sync/main.py @@ -0,0 +1,11 @@ +import sys +from pathlib import Path + +# Add src to python path +src_path = Path(__file__).parent / "src" +sys.path.append(str(src_path)) + +from cli import main + +if __name__ == "__main__": + main() diff --git a/stapler-scripts/llm-sync/pyproject.toml b/stapler-scripts/llm-sync/pyproject.toml new file mode 100644 index 0000000..d10e60e --- /dev/null +++ b/stapler-scripts/llm-sync/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "llm-sync" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "pyyaml>=6.0.3", + "rich>=14.3.2", +] diff --git a/stapler-scripts/llm-sync/src/__init__.py b/stapler-scripts/llm-sync/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stapler-scripts/llm-sync/src/cli.py b/stapler-scripts/llm-sync/src/cli.py new file mode 100644 index 0000000..7d169dd --- /dev/null +++ b/stapler-scripts/llm-sync/src/cli.py @@ -0,0 +1,78 @@ +import argparse +import sys +from pathlib import Path +from rich.console import Console + +# Allow running from src directly or as module +try: + from .sources.claude import ClaudeSource + from .targets.gemini import GeminiTarget + from .targets.opencode import OpenCodeTarget +except ImportError: + # Fallback if run as script (hacky but useful during dev) + sys.path.append(str(Path(__file__).parent)) + from sources.claude import ClaudeSource + from targets.gemini import GeminiTarget + from targets.opencode import OpenCodeTarget + +console = Console() + +def main(): + parser = argparse.ArgumentParser(description="Sync LLM agents from Claude to Gemini and OpenCode") + parser.add_argument("--dry-run", action="store_true", help="Preview changes") + parser.add_argument("--force", action="store_true", help="Overwrite existing agents") + parser.add_argument("--target", choices=['gemini', 'opencode', 'all'], default='all', help="Target platform(s) to sync to") + + args = parser.parse_args() + + console.print("[bold]Starting LLM Agent Sync[/bold]") + + # 1. Load from Source + try: + source = ClaudeSource() + agents = source.load_agents() + skills = source.load_skills() + commands = source.load_commands() + + console.print(f"Found {len(agents)} agents, {len(skills)} skills, and {len(commands)} commands") + + if not any([agents, skills, commands]): + console.print("[yellow]Nothing found to sync. Check configuration paths.[/yellow]") + return + + # 2. Save to Targets + targets = [] + if args.target in ['gemini', 'all']: + targets.append(GeminiTarget()) + if args.target in ['opencode', 'all']: + targets.append(OpenCodeTarget()) + + for target in targets: + target_name = target.__class__.__name__ + console.print(f"\n[bold]Syncing to {target_name}...[/bold]") + + counts = [] + if agents: + a_saved = target.save_agents(agents, dry_run=args.dry_run, force=args.force) + counts.append(f"{a_saved} agents") + + if skills: + s_saved = target.save_skills(skills, dry_run=args.dry_run, force=args.force) + counts.append(f"{s_saved} skills") + + if commands: + c_saved = target.save_commands(commands, dry_run=args.dry_run, force=args.force) + counts.append(f"{c_saved} commands") + + if counts: + console.print(f"[green]Saved {', '.join(counts)} to {target_name}[/green]") + + console.print(f"\n[bold green]Sync Complete.[/bold green]") + + except Exception as e: + console.print(f"[bold red]An error occurred:[/bold red] {e}") + import traceback + console.print(traceback.format_exc()) + +if __name__ == "__main__": + main() diff --git a/stapler-scripts/llm-sync/src/core.py b/stapler-scripts/llm-sync/src/core.py new file mode 100644 index 0000000..c802b22 --- /dev/null +++ b/stapler-scripts/llm-sync/src/core.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +from abc import ABC, abstractmethod + +@dataclass(kw_only=True) +class SyncItem(ABC): + """Base class for items that can be synced.""" + name: str + description: str + metadata: Dict[str, Any] = field(default_factory=dict) + source_file: Optional[str] = None + +@dataclass(kw_only=True) +class Agent(SyncItem): + """Universal representation of an LLM agent/subagent.""" + content: str # The system prompt / instructions + tools: Dict[str, bool] = field(default_factory=dict) + +@dataclass(kw_only=True) +class Skill(SyncItem): + """Legacy/directory-based LLM skill.""" + content: str + tools: Dict[str, bool] = field(default_factory=dict) + +@dataclass(kw_only=True) +class Command(SyncItem): + """Universal representation of a CLI command.""" + content: str # The command prompt/template + +class SyncSource(ABC): + def load_agents(self) -> List[Agent]: + """Load agents from the source.""" + return [] + + def load_skills(self) -> List[Skill]: + """Load skills from the source.""" + return [] + + def load_commands(self) -> List[Command]: + """Load commands from the source.""" + return [] + +class SyncTarget(ABC): + def save_agents(self, agents: List[Agent], dry_run: bool = False, force: bool = False) -> int: + """Save agents to the target.""" + return 0 + + def save_skills(self, skills: List[Skill], dry_run: bool = False, force: bool = False) -> int: + """Save skills to the target.""" + return 0 + + def save_commands(self, commands: List[Command], dry_run: bool = False, force: bool = False) -> int: + """Save commands to the target.""" + return 0 diff --git a/stapler-scripts/llm-sync/src/mappings.py b/stapler-scripts/llm-sync/src/mappings.py new file mode 100644 index 0000000..f89ef63 --- /dev/null +++ b/stapler-scripts/llm-sync/src/mappings.py @@ -0,0 +1,56 @@ +from typing import Dict, List, Set + +# Canonical list of Gemini tools +GEMINI_TOOLS: Set[str] = { + 'list_directory', + 'read_file', + 'write_file', + 'glob', + 'search_file_content', + 'replace', + 'run_shell_command', + 'web_fetch', + 'google_web_search', + 'save_memory', + 'write_todos', + 'delegate_to_agent', + 'activate_skill' +} + +# Mapping from Claude tool names (and common aliases) to Gemini tool names +CLAUDE_TO_GEMINI_TOOL_MAP: Dict[str, str] = { + # File System + 'read': 'read_file', + 'read_file': 'read_file', + 'write': 'write_file', + 'write_file': 'write_file', + 'edit': 'replace', + 'replace': 'replace', + 'ls': 'list_directory', + 'list_directory': 'list_directory', + 'glob': 'glob', + 'grep': 'search_file_content', + 'search': 'search_file_content', + + # Shell + 'bash': 'run_shell_command', + 'run_shell_command': 'run_shell_command', + 'sh': 'run_shell_command', + + # Web + 'webfetch': 'web_fetch', + 'web_fetch': 'web_fetch', + 'google_search': 'google_web_search', + 'google_web_search': 'google_web_search', + + # Task/Memory + 'task': 'write_todos', + 'todo': 'write_todos', + 'memory': 'save_memory', + 'remember': 'save_memory' +} + +def map_tool(tool_name: str) -> str: + """Normalize a tool name to its Gemini equivalent, or return None if unknown.""" + norm = tool_name.lower().strip() + return CLAUDE_TO_GEMINI_TOOL_MAP.get(norm) diff --git a/stapler-scripts/llm-sync/src/sources/__init__.py b/stapler-scripts/llm-sync/src/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stapler-scripts/llm-sync/src/sources/claude.py b/stapler-scripts/llm-sync/src/sources/claude.py new file mode 100644 index 0000000..2b9d81e --- /dev/null +++ b/stapler-scripts/llm-sync/src/sources/claude.py @@ -0,0 +1,200 @@ +import yaml +from pathlib import Path +from typing import List, Dict, Any, Optional +from core import Agent, Skill, Command, SyncSource +from mappings import map_tool, GEMINI_TOOLS +from rich.console import Console + +console = Console() + +class ClaudeSource(SyncSource): + def __init__(self, agents_dir: Optional[Path] = None, skills_dir: Optional[Path] = None, commands_dir: Optional[Path] = None): + self.agents_dir = agents_dir or Path.home() / ".claude" / "agents" + self.skills_dir = skills_dir or Path.home() / ".claude" / "skills" + self.commands_dir = commands_dir or Path.home() / ".claude" / "commands" + + def load_agents(self) -> List[Agent]: + agents = [] + if self.agents_dir.exists(): + for agent_file in self.agents_dir.glob("**/*.md"): + agent = self._load_agent(agent_file) + if agent: + agents.append(agent) + return agents + + def load_skills(self) -> List[Skill]: + skills = [] + if self.skills_dir.exists(): + # Claude "skills" (legacy/plugin based) are often just md files too + for skill_file in self.skills_dir.glob("**/*.md"): + # We reuse _load_agent logic but wrap as Skill + # Or parsing might be simpler if they don't have frontmatter + # Let's assume similar format for now + agent = self._load_agent(skill_file) + if agent: + skills.append(Skill( + name=agent.name, + description=agent.description, + content=agent.content, + tools=agent.tools, + metadata=agent.metadata, + source_file=agent.source_file + )) + return skills + + def load_commands(self) -> List[Command]: + commands = [] + if self.commands_dir.exists(): + for cmd_file in self.commands_dir.glob("**/*.md"): + try: + with open(cmd_file, 'r', encoding='utf-8') as f: + content = f.read() + + # Assume commands are simple markdown or frontmatter+markdown + # If they have frontmatter, we parse it. + name = cmd_file.stem + description = "" + cmd_content = content + metadata = {} + + if content.startswith('---'): + parts = content.split('---', 2) + if len(parts) >= 3: + frontmatter = parts[1].strip() + cmd_content = parts[2].strip() + try: + metadata = yaml.safe_load(frontmatter) + except yaml.YAMLError: + metadata = self._parse_frontmatter_manually(frontmatter) + + if metadata: + description = metadata.get('description', '') + # Name in frontmatter overrides filename + if 'name' in metadata: + name = metadata['name'] + + commands.append(Command( + name=name, + description=description, + content=cmd_content, + metadata=metadata, + source_file=str(cmd_file) + )) + except Exception as e: + console.print(f"[red]Error reading command {cmd_file}: {e}[/red]") + return commands + + def _load_agent(self, agent_file: Path) -> Optional[Agent]: + try: + with open(agent_file, 'r', encoding='utf-8') as f: + content = f.read() + + if content.startswith('---'): + parts = content.split('---', 2) + if len(parts) >= 3: + frontmatter = parts[1].strip() + agent_content = parts[2].strip() + + try: + metadata = yaml.safe_load(frontmatter) + except yaml.YAMLError: + metadata = self._parse_frontmatter_manually(frontmatter) + + if not metadata: + return None + + name = metadata.get('name') or agent_file.stem + description = metadata.get('description', '') + + # Convert tools + claude_tools = metadata.get('tools', []) + tools = self._convert_tools(claude_tools) + + return Agent( + name=name, + description=description, + content=agent_content, + tools=tools, + metadata=metadata, + source_file=str(agent_file) + ) + except Exception as e: + console.print(f"[red]Error reading {agent_file}: {e}[/red]") + return None + + def _parse_frontmatter_manually(self, frontmatter: str) -> Optional[Dict[str, Any]]: + """Manually parse frontmatter when YAML parsing fails.""" + lines = frontmatter.split('\n') + metadata = {} + current_key = None + current_value_lines = [] + + i = 0 + while i < len(lines): + line = lines[i] + + # Check for key: value pattern + if ':' in line and not line.startswith(' '): + # Save previous key-value pair + if current_key: + value = '\n'.join(current_value_lines).strip() + metadata[current_key] = value + + # Start new key-value pair + parts = line.split(':', 1) + current_key = parts[0].strip() + value_start = parts[1].strip() if len(parts) > 1 else '' + current_value_lines = [value_start] + elif current_key and line.startswith(' '): + # Continuation of multi-line value + current_value_lines.append(line) + elif line.strip() == '': + # Empty line - could be separator + pass + else: + # Unexpected line, might be malformed + pass + + i += 1 + + # Save the last key-value pair + if current_key: + value = '\n'.join(current_value_lines).strip() + metadata[current_key] = value + + return metadata if metadata else None + + def _convert_tools(self, claude_tools: Any) -> Dict[str, bool]: + """Convert Claude tool definitions to Gemini tool map using shared mappings.""" + result = {} + + # Helper to process a single tool string + def process_tool(t_name): + t_name = t_name.lower().strip() + + # Handle wildcards + if t_name in ['*', 'all']: + for tool in GEMINI_TOOLS: + result[tool] = True + return + + # Handle specific tools + gemini_tool = map_tool(t_name) + if gemini_tool: + result[gemini_tool] = True + else: + # Keep unknown tools but mark as False (or handle differently if needed) + # For now, we only enable mapped tools. + pass + + if isinstance(claude_tools, str): + if ',' in claude_tools: + for t in claude_tools.split(','): + process_tool(t) + else: + process_tool(claude_tools) + elif isinstance(claude_tools, list): + for t in claude_tools: + process_tool(str(t)) + + return result diff --git a/stapler-scripts/llm-sync/src/targets/__init__.py b/stapler-scripts/llm-sync/src/targets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stapler-scripts/llm-sync/src/targets/gemini.py b/stapler-scripts/llm-sync/src/targets/gemini.py new file mode 100644 index 0000000..923d79f --- /dev/null +++ b/stapler-scripts/llm-sync/src/targets/gemini.py @@ -0,0 +1,132 @@ +import yaml +from pathlib import Path +from typing import List, Optional +from core import Agent, Skill, Command, SyncTarget +from mappings import GEMINI_TOOLS +from rich.console import Console + +console = Console() + +class GeminiTarget(SyncTarget): + def __init__(self, agents_dir: Optional[Path] = None, skills_dir: Optional[Path] = None, commands_dir: Optional[Path] = None): + self.agents_dir = agents_dir or Path.home() / ".gemini" / "agents" + self.skills_dir = skills_dir or Path.home() / ".gemini" / "skills" + self.commands_dir = commands_dir or Path.home() / ".gemini" / "commands" + + def save_agents(self, agents: List[Agent], dry_run: bool = False, force: bool = False) -> int: + self.agents_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + + for agent in agents: + # Gemini sub-agents are .md files with YAML frontmatter + agent_file = self.agents_dir / f"{agent.name}.md" + + if agent_file.exists() and not force: + console.print(f"[yellow]Skipping agent {agent.name} (exists). Use --force to overwrite.[/yellow]") + continue + + # Construct YAML frontmatter + enabled_tools = [t for t, enabled in agent.tools.items() if enabled and t in GEMINI_TOOLS] + + frontmatter = { + 'name': agent.name, + 'description': agent.description, + } + + if enabled_tools: + frontmatter['tools'] = enabled_tools + + for key in ['model', 'temperature', 'max_turns', 'timeout_mins']: + if key in agent.metadata: + frontmatter[key] = agent.metadata[key] + + fm_yaml = yaml.dump(frontmatter, sort_keys=False) + + full_content = f"---\n{fm_yaml}---\n\n{agent.content}" + + if dry_run: + console.print(f"[blue]Would write {agent_file}[/blue]") + else: + with open(agent_file, 'w', encoding='utf-8') as f: + f.write(full_content) + console.print(f"[green]Saved agent {agent.name}[/green]") + saved_count += 1 + + return saved_count + + def save_skills(self, skills: List[Skill], dry_run: bool = False, force: bool = False) -> int: + self.skills_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + + for skill in skills: + # Legacy Gemini skills are directories with a SKILL.md file + skill_dir = self.skills_dir / skill.name + skill_file = skill_dir / "SKILL.md" + + if skill_file.exists() and not force: + console.print(f"[yellow]Skipping skill {skill.name} (exists). Use --force to overwrite.[/yellow]") + continue + + frontmatter = { + 'name': skill.name, + 'description': skill.description, + } + + fm_yaml = yaml.dump(frontmatter, sort_keys=False) + full_content = f"---\n{fm_yaml}---\n\n{skill.content}" + + if dry_run: + console.print(f"[blue]Would write {skill_file}[/blue]") + else: + skill_dir.mkdir(exist_ok=True) + with open(skill_file, 'w', encoding='utf-8') as f: + f.write(full_content) + console.print(f"[green]Saved skill {skill.name}[/green]") + saved_count += 1 + + return saved_count + + def save_commands(self, commands: List[Command], dry_run: bool = False, force: bool = False) -> int: + self.commands_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + + for cmd in commands: + # Gemini commands are TOML files + # Handle namespacing (e.g. "git/commit" -> git/commit.toml) + cmd_path = self.commands_dir / f"{cmd.name}.toml" + + if cmd_path.exists() and not force: + console.print(f"[yellow]Skipping command {cmd.name} (exists). Use --force to overwrite.[/yellow]") + continue + + # Convert content placeholders + # OpenCode uses $ARGUMENTS, Gemini uses {{args}} + content = cmd.content.replace("$ARGUMENTS", "{{args}}") + + # Construct TOML content + # We manually construct to ensure format is clean, or use a library if complex + # For simple key-values, f-strings are fine and avoid extra deps + + # Escape backslashes, quotes, and newlines in description + desc_safe = cmd.description.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ') + + # Construct TOML content + # Prefer literal multi-line strings (''') to avoid escaping issues + if "'''" not in content: + toml_content = f'description = "{desc_safe}"\n\nprompt = \'\'\'\n{content}\n\'\'\'\n' + else: + # Fallback to basic multi-line strings (""") if literal quotes present + # Must escape backslashes and triple quotes + content_safe = content.replace('\\', '\\\\').replace('"""', '\\"\\"\\"') + toml_content = f'description = "{desc_safe}"\n\nprompt = """\n{content_safe}\n"""\n' + + if dry_run: + console.print(f"[blue]Would write {cmd_path}[/blue]") + else: + cmd_path.parent.mkdir(parents=True, exist_ok=True) + with open(cmd_path, 'w', encoding='utf-8') as f: + f.write(toml_content) + console.print(f"[green]Saved command {cmd.name}[/green]") + saved_count += 1 + + return saved_count diff --git a/stapler-scripts/llm-sync/src/targets/opencode.py b/stapler-scripts/llm-sync/src/targets/opencode.py new file mode 100644 index 0000000..5e5fbe9 --- /dev/null +++ b/stapler-scripts/llm-sync/src/targets/opencode.py @@ -0,0 +1,95 @@ +import yaml +from pathlib import Path +from typing import List, Dict, Any, Optional +from core import Agent, Skill, Command, SyncTarget +from mappings import map_tool +from rich.console import Console + +console = Console() + +class OpenCodeTarget(SyncTarget): + def __init__(self, agents_dir: Optional[Path] = None, commands_dir: Optional[Path] = None): + self.agents_dir = agents_dir or Path.home() / ".config" / "opencode" / "agents" + self.commands_dir = commands_dir or Path.home() / ".config" / "opencode" / "commands" + + def save_agents(self, agents: List[Agent], dry_run: bool = False, force: bool = False) -> int: + self.agents_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + + for agent in agents: + # OpenCode agents are single .md files + agent_file = self.agents_dir / f"{agent.name}.md" + + if agent_file.exists() and not force: + console.print(f"[yellow]Skipping agent {agent.name} (exists). Use --force to overwrite.[/yellow]") + continue + + opencode_tools = {t: True for t, enabled in agent.tools.items() if enabled} + + frontmatter = { + 'description': agent.description, + 'mode': 'subagent', + 'temperature': 0.1, + 'tools': opencode_tools + } + + for key in ['model', 'temperature', 'max_steps', 'permission', 'color', 'arguments']: + if key in agent.metadata: + frontmatter[key] = agent.metadata[key] + + fm_yaml = yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) + content = f"---\n{fm_yaml}---\n\n{agent.content}" + + if dry_run: + console.print(f"[blue]Would write {agent_file}[/blue]") + else: + with open(agent_file, 'w', encoding='utf-8') as f: + f.write(content) + console.print(f"[green]Saved agent {agent.name} to OpenCode[/green]") + saved_count += 1 + + return saved_count + + def save_skills(self, skills: List[Skill], dry_run: bool = False, force: bool = False) -> int: + # OpenCode doesn't have a distinct "Skill" concept like legacy Gemini, + # so we map them to agents but maybe with different metadata or mode. + agents = [Agent(name=s.name, description=s.description, content=s.content, tools=s.tools, metadata=s.metadata) for s in skills] + return self.save_agents(agents, dry_run=dry_run, force=force) + + def save_commands(self, commands: List[Command], dry_run: bool = False, force: bool = False) -> int: + self.commands_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + + for cmd in commands: + # OpenCode commands are .md files + cmd_path = self.commands_dir / f"{cmd.name}.md" + + if cmd_path.exists() and not force: + console.print(f"[yellow]Skipping command {cmd.name} (exists). Use --force to overwrite.[/yellow]") + continue + + # Convert content placeholders + # Gemini uses {{args}}, OpenCode uses $ARGUMENTS + content = cmd.content.replace("{{args}}", "$ARGUMENTS") + + frontmatter = { + 'description': cmd.description + } + + if 'arguments' in cmd.metadata: + frontmatter['arguments'] = cmd.metadata['arguments'] + + fm_yaml = yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) + + full_content = f"---\n{fm_yaml}---\n\n{content}" + + if dry_run: + console.print(f"[blue]Would write {cmd_path}[/blue]") + else: + cmd_path.parent.mkdir(parents=True, exist_ok=True) + with open(cmd_path, 'w', encoding='utf-8') as f: + f.write(full_content) + console.print(f"[green]Saved command {cmd.name} to OpenCode[/green]") + saved_count += 1 + + return saved_count diff --git a/stapler-scripts/llm-sync/uv.lock b/stapler-scripts/llm-sync/uv.lock new file mode 100644 index 0000000..e01d92e --- /dev/null +++ b/stapler-scripts/llm-sync/uv.lock @@ -0,0 +1,87 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "llm-sync" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "rich", specifier = ">=14.3.2" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] diff --git a/stapler-scripts/sync-claude-to-opencode.py b/stapler-scripts/sync-claude-to-opencode.py deleted file mode 100755 index 5997ea6..0000000 --- a/stapler-scripts/sync-claude-to-opencode.py +++ /dev/null @@ -1,449 +0,0 @@ -#!/usr/bin/env python3 -""" -sync-claude-to-opencode.py - -A tool to sync Claude Code agents and commands to OpenCode format with automatic format translation. -Agents and commands will inherit OpenCode's default model configuration. - -Usage: - python sync-claude-to-opencode.py [--dry-run] [--force] - -Options: - --dry-run Show what would be done without making changes - --force Overwrite existing opencode agents/commands without prompting -""" -# /// script -# requires-python = ">=3.8" -# dependencies = [ -# "pyyaml", -# ] -# /// - -import os -import sys -import argparse -import yaml -from pathlib import Path -from typing import Dict, Any, List, Optional - -class ClaudeToOpenCodeConverter: - """Converts Claude Code agents/commands to OpenCode agent/command format.""" - - def __init__(self): - # Support both global agents and project-specific commands - self.claude_agents_dir = Path.home() / ".claude" / "agents" - self.claude_commands_dir = Path.home() / "Documents" / "personal-wiki" / ".claude" / "commands" - self.opencode_agents_dir = Path.home() / ".config" / "opencode" / "agent" - self.opencode_commands_dir = Path.home() / "Documents" / "personal-wiki" / ".opencode" / "commands" - self.opencode_agents_dir.mkdir(parents=True, exist_ok=True) - self.opencode_commands_dir.mkdir(parents=True, exist_ok=True) - - def load_claude_agent(self, agent_file: Path) -> Optional[Dict[str, Any]]: - """Load and parse a Claude Code agent file.""" - try: - with open(agent_file, 'r', encoding='utf-8') as f: - content = f.read() - - # Split frontmatter from content - if content.startswith('---'): - parts = content.split('---', 2) - if len(parts) >= 3: - frontmatter = parts[1].strip() - agent_content = parts[2].strip() - - # Try to parse with YAML first - try: - metadata = yaml.safe_load(frontmatter) - metadata['_content'] = agent_content - return metadata - except yaml.YAMLError: - # If YAML parsing fails, try manual parsing - metadata = self._parse_frontmatter_manually(frontmatter) - if metadata: - metadata['_content'] = agent_content - return metadata - else: - print(f"Error parsing YAML in {agent_file}: YAML parsing failed and manual parsing failed") - return None - else: - print(f"Invalid frontmatter format in {agent_file}") - return None - else: - print(f"No frontmatter found in {agent_file}") - return None - - except Exception as e: - print(f"Error reading {agent_file}: {e}") - return None - - def _parse_frontmatter_manually(self, frontmatter: str) -> Optional[Dict[str, Any]]: - """Manually parse frontmatter when YAML parsing fails.""" - lines = frontmatter.split('\n') - metadata = {} - current_key = None - current_value_lines = [] - - i = 0 - while i < len(lines): - line = lines[i] - - # Check for key: value pattern - if ':' in line and not line.startswith(' '): - # Save previous key-value pair - if current_key: - value = '\n'.join(current_value_lines).strip() - if current_key == 'tools': - # Special handling for tools - metadata[current_key] = self._parse_tools_value(value) - else: - metadata[current_key] = value - - # Start new key-value pair - parts = line.split(':', 1) - current_key = parts[0].strip() - value_start = parts[1].strip() if len(parts) > 1 else '' - current_value_lines = [value_start] - elif current_key and line.startswith(' '): - # Continuation of multi-line value - current_value_lines.append(line) - elif line.strip() == '': - # Empty line - could be separator - pass - else: - # Unexpected line, might be malformed - pass - - i += 1 - - # Save the last key-value pair - if current_key: - value = '\n'.join(current_value_lines).strip() - if current_key == 'tools': - metadata[current_key] = self._parse_tools_value(value) - else: - metadata[current_key] = value - - return metadata if metadata else None - - def _parse_tools_value(self, tools_str: str) -> Any: - """Parse tools value which might be a string, list, or special syntax.""" - tools_str = tools_str.strip() - if tools_str == '*' or tools_str == 'all': - return 'all' - elif ',' in tools_str: - return [t.strip() for t in tools_str.split(',')] - elif tools_str: - return tools_str - else: - return [] - - def _fix_yaml_issues(self, frontmatter: str) -> str: - """Fix common YAML issues found in Claude agents.""" - # Fix tools: * which is invalid YAML (interpreted as alias) - frontmatter = frontmatter.replace('tools: *', 'tools: all') - - # Handle complex multi-line descriptions with examples and colons - # The Claude agents have descriptions followed by "Examples:" sections that break YAML - # We need to extract only the actual description text - - lines = frontmatter.split('\n') - fixed_lines = [] - i = 0 - - while i < len(lines): - line = lines[i] - - if line.strip().startswith('description:'): - # Start of description field - desc_parts = [] - desc_start = line.split(':', 1)[1].strip() - - if desc_start: - desc_parts.append(desc_start) - - i += 1 - # Collect description lines until we hit a line that looks like a new top-level key - # or "Examples:" which indicates the end of the description - while i < len(lines): - next_line = lines[i] - next_line_stripped = next_line.strip() - - # Stop if we hit "Examples:" or another top-level key (word: not indented) - if next_line_stripped.startswith('Examples:') or \ - (next_line_stripped and not next_line.startswith(' ') and ':' in next_line_stripped and not next_line_stripped.startswith('-')): - break - - desc_parts.append(next_line) - i += 1 - - # Join the description and properly format it for YAML - description = '\n'.join(desc_parts).strip() - if description: - # Use literal block for multi-line descriptions - fixed_lines.append('description: |') - for desc_line in description.split('\n'): - fixed_lines.append(f' {desc_line}') - else: - fixed_lines.append('description: ""') - - # Don't increment i here as we've already consumed the lines - continue - - else: - fixed_lines.append(line) - i += 1 - - return '\n'.join(fixed_lines) - - def convert_tools_format(self, claude_tools: Any) -> Dict[str, bool]: - """Convert Claude Code tools format to OpenCode tools format.""" - opencode_tools = {} - - # Claude tool name to OpenCode tool name mapping - tool_mapping = { - 'bash': 'bash', - 'read': 'read', - 'write': 'write', - 'edit': 'edit', - 'glob': 'glob', - 'grep': 'grep', - 'webfetch': 'webfetch', - 'task': 'task', - 'todowrite': 'todowrite', - 'todoread': 'todoread', - # Case variations - 'Bash': 'bash', - 'Read': 'read', - 'Write': 'write', - 'Edit': 'edit', - 'Glob': 'glob', - 'Grep': 'grep', - 'WebFetch': 'webfetch', - 'Task': 'task', - 'TodoWrite': 'todowrite', - 'TodoRead': 'todoread', - } - - # Default all known tools to False - default_tools = {v: False for v in tool_mapping.values()} - - if claude_tools == '*' or claude_tools == 'all': - # Enable all known tools - opencode_tools = {k: True for k in default_tools.keys()} - elif isinstance(claude_tools, str): - # Parse comma-separated string - tool_names = [t.strip() for t in claude_tools.split(',')] - for tool in tool_names: - tool = tool.strip() - if tool == '*' or tool == 'all': - opencode_tools = {k: True for k in default_tools.keys()} - break - elif tool in tool_mapping: - mapped_tool = tool_mapping[tool] - opencode_tools[mapped_tool] = True - else: - # Skip unknown tools quietly for MCP tools and other extensions - pass - elif isinstance(claude_tools, list): - # Handle array format - for tool in claude_tools: - tool = str(tool).strip() - if tool == '*' or tool == 'all': - opencode_tools = {k: True for k in default_tools.keys()} - break - elif tool in tool_mapping: - mapped_tool = tool_mapping[tool] - opencode_tools[mapped_tool] = True - else: - # Skip unknown tools quietly - pass - - # Merge with defaults - result = default_tools.copy() - result.update(opencode_tools) - return result - - - - def convert_agent(self, claude_metadata: Dict[str, Any]) -> Dict[str, Any]: - """Convert Claude Code agent metadata to OpenCode format.""" - opencode_agent = {} - - # Required fields - opencode_agent['description'] = claude_metadata.get('description', 'Converted from Claude Code agent') - - # Optional fields with defaults - opencode_agent['mode'] = 'subagent' # Most Claude agents are specialized - # Skip model field to use OpenCode's default model configuration - opencode_agent['temperature'] = 0.1 # Low temperature for consistency - - # Convert tools - claude_tools = claude_metadata.get('tools', []) - opencode_agent['tools'] = self.convert_tools_format(claude_tools) - - # Add content - opencode_agent['_content'] = claude_metadata.get('_content', '') - - return opencode_agent - - def convert_command(self, claude_metadata: Dict[str, Any]) -> Dict[str, Any]: - """Convert Claude Code command metadata to OpenCode format.""" - opencode_command = {} - - # Required fields - map title to description - title = claude_metadata.get('title', '') - description = claude_metadata.get('description', '') - if title: - opencode_command['description'] = f"{title}: {description}" if description else title - else: - opencode_command['description'] = description or 'Converted from Claude Code command' - - # Optional fields with defaults - opencode_command['mode'] = 'command' # This is a command, not an agent - # Skip model field to use OpenCode's default model configuration - opencode_command['temperature'] = 0.1 # Low temperature for consistency - - # Convert tools - claude_tools = claude_metadata.get('tools', []) - opencode_command['tools'] = self.convert_tools_format(claude_tools) - - # Add arguments if present - arguments = claude_metadata.get('arguments', []) - if arguments: - opencode_command['arguments'] = arguments - - # Add content - opencode_command['_content'] = claude_metadata.get('_content', '') - - return opencode_command - - def generate_opencode_markdown(self, agent_data: Dict[str, Any]) -> str: - """Generate OpenCode markdown format from agent data.""" - frontmatter = {} - - # Copy relevant fields - for key in ['description', 'mode', 'model', 'temperature', 'tools', 'arguments']: - if key in agent_data: - frontmatter[key] = agent_data[key] - - # Generate YAML frontmatter - frontmatter_yaml = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True) - - # Combine with content - content = agent_data.get('_content', '') - if content: - return f"---\n{frontmatter_yaml}---\n\n{content}" - else: - return f"---\n{frontmatter_yaml}---" - - def sync_all(self, dry_run: bool = False, force: bool = False): - """Sync all Claude Code agents and commands to OpenCode format.""" - total_synced = 0 - total_skipped = 0 - - # Sync agents - agents_synced, agents_skipped = self._sync_items( - self.claude_agents_dir, self.opencode_agents_dir, - "agents", self.convert_agent, dry_run, force - ) - total_synced += agents_synced - total_skipped += agents_skipped - - # Sync commands - commands_synced, commands_skipped = self._sync_items( - self.claude_commands_dir, self.opencode_commands_dir, - "commands", self.convert_command, dry_run, force - ) - total_synced += commands_synced - total_skipped += commands_skipped - - print(f"\n{'🎭 Dry run completed' if dry_run else '✨ Sync completed'}") - print(f"📊 Total synced: {total_synced}") - print(f"⏭️ Total skipped: {total_skipped}") - - def _sync_items(self, source_dir: Path, target_dir: Path, item_type: str, - converter_func, dry_run: bool = False, force: bool = False): - """Generic method to sync items (agents or commands).""" - if not source_dir.exists(): - print(f"Claude {item_type} directory not found: {source_dir}") - return 0, 0 - - print(f"\nScanning Claude {item_type} in: {source_dir}") - print(f"Target OpenCode {item_type} directory: {target_dir}") - - item_files = list(source_dir.glob("**/*.md")) - if not item_files: - print(f"No Claude {item_type} files found.") - return 0, 0 - - print(f"Found {len(item_files)} Claude {item_type} files") - - synced_count = 0 - skipped_count = 0 - - for item_file in item_files: - print(f"\nProcessing: {item_file.name}") - - # Load Claude item - claude_metadata = self.load_claude_agent(item_file) # Reusing the same loader - if not claude_metadata: - print(f" ❌ Failed to load Claude {item_type[:-1]}: {item_file.name}") - continue - - # For agents, use name from metadata or filename - # For commands, preserve the directory structure and use original filename - if item_type == "commands": - # Preserve relative path structure - relative_path = item_file.relative_to(source_dir) - opencode_file = target_dir / relative_path - item_name = str(relative_path) # For display purposes - else: - # For agents, use name from metadata or filename - item_name = claude_metadata.get('name') or item_file.stem - opencode_file = target_dir / f"{item_name}.md" - - # Check if target exists - if opencode_file.exists() and not force: - response = input(f" ⚠️ OpenCode {item_type[:-1]} '{item_name}.md' already exists. Overwrite? (y/N): ") - if response.lower() != 'y': - print(f" ⏭️ Skipping: {item_name}") - skipped_count += 1 - continue - - # Convert to OpenCode format - opencode_data = converter_func(claude_metadata) - opencode_content = self.generate_opencode_markdown(opencode_data) - - if dry_run: - print(f" 📝 Would create: {opencode_file}") - print(" 📋 Content preview:") - lines = opencode_content.split('\n')[:10] - for line in lines: - print(f" {line}") - if len(opencode_content.split('\n')) > 10: - print(" ...") - else: - try: - # Ensure parent directories exist - opencode_file.parent.mkdir(parents=True, exist_ok=True) - with open(opencode_file, 'w', encoding='utf-8') as f: - f.write(opencode_content) - print(f" ✅ Created: {opencode_file}") - synced_count += 1 - except Exception as e: - print(f" ❌ Failed to write {opencode_file}: {e}") - - return synced_count, skipped_count - -def main(): - parser = argparse.ArgumentParser(description="Sync Claude Code agents and commands to OpenCode format") - parser.add_argument('--dry-run', action='store_true', help="Show what would be done without making changes") - parser.add_argument('--force', action='store_true', help="Overwrite existing OpenCode agents without prompting") - - args = parser.parse_args() - - converter = ClaudeToOpenCodeConverter() - converter.sync_all(dry_run=args.dry_run, force=args.force) - -if __name__ == '__main__': - main() \ No newline at end of file