td-ai-tools 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +49 -0
  2. package/agents/README.md +10 -0
  3. package/agents/horizon-component-library/AGENTS.md +184 -0
  4. package/agents/horizon-component-library/README.md +10 -0
  5. package/agents/horizon-component-library/scripts/td-guard.sh +49 -0
  6. package/bin/cli.js +209 -0
  7. package/package.json +23 -0
  8. package/scripts/smoke-install.sh +56 -0
  9. package/skills/README.md +15 -0
  10. package/skills/cache-reset/SKILL.md +18 -0
  11. package/skills/cache-reset/agents/openai.yaml +4 -0
  12. package/skills/car-ticket-generator/SKILL.md +130 -0
  13. package/skills/car-ticket-generator/agents/openai.yaml +4 -0
  14. package/skills/everhour-basecamp-estimates/.env.example +2 -0
  15. package/skills/everhour-basecamp-estimates/SKILL.md +73 -0
  16. package/skills/everhour-basecamp-estimates/agents/openai.yaml +4 -0
  17. package/skills/everhour-basecamp-estimates/scripts/update_estimates.py +518 -0
  18. package/skills/everhour-basecamp-estimates/tests/test_update_estimates.py +93 -0
  19. package/skills/horizon-component-migration/SKILL.md +59 -0
  20. package/skills/horizon-component-migration/agents/openai.yaml +4 -0
  21. package/skills/pr-solver/SKILL.md +50 -0
  22. package/skills/pr-solver/agents/openai.yaml +4 -0
  23. package/skills/pr-solver/references/github-pr-reviewthreads-graphql.md +63 -0
  24. package/skills/pr-solver/scripts/list_unresolved_threads.py +307 -0
  25. package/skills/pull-request/SKILL.md +216 -0
  26. package/skills/pull-request/agents/openai.yaml +4 -0
  27. package/skills/record-changes/SKILL.md +75 -0
  28. package/skills/record-changes/agents/openai.yaml +4 -0
  29. package/skills/record-changes/scripts/branch_diff_context.py +190 -0
  30. package/skills/stylesheet-migration/SKILL.md +36 -0
  31. package/skills/stylesheet-migration/agents/openai.yaml +6 -0
  32. package/skills/stylesheet-migration/scripts/__pycache__/liquid_stylesheet_migrator.cpython-312.pyc +0 -0
  33. package/skills/stylesheet-migration/scripts/__pycache__/test_liquid_stylesheet_migrator.cpython-312.pyc +0 -0
  34. package/skills/stylesheet-migration/scripts/liquid_stylesheet_migrator.py +204 -0
  35. package/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py +172 -0
  36. package/skills/stylesheet-migration/scripts/test_liquid_stylesheet_migrator.py +254 -0
  37. package/skills/td-js-vanilla-rules/SKILL.md +70 -0
  38. package/skills/td-js-vanilla-rules/agents/openai.yaml +3 -0
  39. package/skills/td-review/SKILL.md +122 -0
  40. package/skills/td-review/agents/openai.yaml +4 -0
  41. package/skills/td-review/agents/td-theme-reviewer.md +221 -0
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: record-changes
3
+ description: Update `docs/changes.md` by summarizing the current branch against the primary development branch. Use when a developer asks to record branch changes, document theme customizations, refresh the project change log, or write a branch summary into `docs/changes.md`. Prefer comparing against `main`, but fall back to `origin/main`, `master`, or `origin/master` when the repository does not have a local `main` branch.
4
+ ---
5
+
6
+ # Record Changes
7
+
8
+ Update `docs/changes.md` from the current branch diff. Preserve the existing document structure and add a concise, human-readable entry for the branch.
9
+
10
+ ## Workflow
11
+
12
+ ### 1. Collect Branch Context
13
+
14
+ Run the bundled script first:
15
+
16
+ ```bash
17
+ python3 .agents/skills/record-changes/scripts/branch_diff_context.py
18
+ ```
19
+
20
+ If the developer specifies another comparison branch, pass it explicitly:
21
+
22
+ ```bash
23
+ python3 .agents/skills/record-changes/scripts/branch_diff_context.py --base release/x.y
24
+ ```
25
+
26
+ Read the existing `docs/changes.md` before editing so the new entry matches the current ordering, tone, and section layout.
27
+
28
+ ### 2. Inspect the Actual Code Changes
29
+
30
+ Use the script output to identify changed files, then inspect the relevant diffs and file contents with `git diff` and targeted file reads.
31
+
32
+ Prioritize:
33
+
34
+ - User-facing behavior changes
35
+ - Theme setting/schema changes
36
+ - CSS or markup changes that affect storefront rendering
37
+ - Renamed files and vendor-theme hotspots
38
+
39
+ Do not summarize the `docs/changes.md` edit itself as part of the branch work. If the branch contains unrelated skill or tooling files, either omit them from the changelog entry or separate them clearly when they are relevant to the project's maintenance history.
40
+
41
+ ### 3. Write the Changelog Entry
42
+
43
+ Add a new entry near the top of `docs/changes.md`, directly under the intro, unless the file already uses another ordering convention.
44
+
45
+ Follow the existing pattern:
46
+
47
+ - `## <short title>`
48
+ - `**Date:** YYYY-MM-DD`
49
+ - `### Purpose`
50
+ - `### Files changed`
51
+ - `### Upgrade impact`
52
+ - `### Notes`
53
+
54
+ Guidelines:
55
+
56
+ - Make the title describe the feature or fix, not the branch name.
57
+ - Write `Purpose` in plain language with outcome-focused bullets.
58
+ - Use the `Files changed` table to explain why each file matters.
59
+ - Call out non-`td-` theme or vendor files in a separate subsection when they are upgrade-sensitive.
60
+ - Mention renamed files explicitly.
61
+ - Keep `Upgrade impact` brief and concrete.
62
+ - Use `Notes` for implementation details, edge cases, or assumptions.
63
+
64
+ ### 4. Verify Before Finishing
65
+
66
+ Before wrapping up:
67
+
68
+ - Re-read the new markdown entry in context.
69
+ - Confirm every listed file appears in the diff.
70
+ - Confirm the documented behavior matches the code, not just the branch name.
71
+ - Mention any uncertainty if the diff is too broad to summarize with high confidence.
72
+
73
+ ## Resource
74
+
75
+ - `scripts/branch_diff_context.py`: Resolve the best available base branch and print a branch summary with file statuses and line counts.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: 'Record Changes'
3
+ short_description: 'Summarize branch changes into docs/changes.md'
4
+ default_prompt: 'Use $record-changes to compare this branch to main and update docs/changes.md.'
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ DEFAULT_BASE_CANDIDATES = ("main", "origin/main", "master", "origin/master")
11
+
12
+
13
+ def run_git(*args: str) -> str:
14
+ completed = subprocess.run(
15
+ ["git", *args],
16
+ check=True,
17
+ capture_output=True,
18
+ text=True,
19
+ )
20
+ return completed.stdout.strip()
21
+
22
+
23
+ def ref_exists(ref: str) -> bool:
24
+ completed = subprocess.run(
25
+ ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
26
+ capture_output=True,
27
+ text=True,
28
+ )
29
+ return completed.returncode == 0
30
+
31
+
32
+ def resolve_base_ref(explicit_base: str | None) -> str:
33
+ if explicit_base:
34
+ if not ref_exists(explicit_base):
35
+ raise SystemExit(f"Base ref not found: {explicit_base}")
36
+ return explicit_base
37
+
38
+ for candidate in DEFAULT_BASE_CANDIDATES:
39
+ if ref_exists(candidate):
40
+ return candidate
41
+
42
+ searched = ", ".join(DEFAULT_BASE_CANDIDATES)
43
+ raise SystemExit(f"No base ref found. Tried: {searched}")
44
+
45
+
46
+ def parse_name_status(output: str) -> list[dict[str, str]]:
47
+ files = []
48
+ for line in output.splitlines():
49
+ if not line:
50
+ continue
51
+ parts = line.split("\t")
52
+ status = parts[0]
53
+
54
+ if status.startswith("R") and len(parts) >= 3:
55
+ files.append(
56
+ {
57
+ "status": status,
58
+ "path": parts[2],
59
+ "old_path": parts[1],
60
+ }
61
+ )
62
+ continue
63
+
64
+ path = parts[1] if len(parts) > 1 else ""
65
+ files.append({"status": status, "path": path, "old_path": ""})
66
+ return files
67
+
68
+
69
+ def parse_numstat(output: str) -> dict[str, dict[str, str]]:
70
+ stats = {}
71
+ for line in output.splitlines():
72
+ if not line:
73
+ continue
74
+ parts = line.split("\t")
75
+ if len(parts) < 3:
76
+ continue
77
+ additions, deletions, path = parts[0], parts[1], parts[2]
78
+ stats[path] = {"additions": additions, "deletions": deletions}
79
+ return stats
80
+
81
+
82
+ def resolve_stats(
83
+ numstat: dict[str, dict[str, str]], path: str, old_path: str
84
+ ) -> dict[str, str] | None:
85
+ stats = numstat.get(path)
86
+ if stats or not old_path:
87
+ return stats
88
+
89
+ path_dir = os.path.dirname(path)
90
+ old_dir = os.path.dirname(old_path)
91
+ path_name = os.path.basename(path)
92
+ old_name = os.path.basename(old_path)
93
+
94
+ for candidate_path, candidate_stats in numstat.items():
95
+ if old_path in candidate_path and path in candidate_path:
96
+ return candidate_stats
97
+ if (
98
+ path_dir == old_dir
99
+ and path_dir
100
+ and candidate_path.startswith(f"{path_dir}/")
101
+ and old_name in candidate_path
102
+ and path_name in candidate_path
103
+ ):
104
+ return candidate_stats
105
+
106
+ return None
107
+
108
+
109
+ def build_context(base_ref: str) -> dict[str, object]:
110
+ current_branch = run_git("branch", "--show-current")
111
+ merge_base = run_git("merge-base", base_ref, "HEAD")
112
+ shortstat = run_git("diff", "--shortstat", merge_base, "HEAD")
113
+ name_status = parse_name_status(
114
+ run_git("diff", "--name-status", "--find-renames", merge_base, "HEAD")
115
+ )
116
+ numstat = parse_numstat(run_git("diff", "--numstat", "--find-renames", merge_base, "HEAD"))
117
+
118
+ for entry in name_status:
119
+ stats = resolve_stats(numstat, entry["path"], entry["old_path"])
120
+ entry["additions"] = stats["additions"] if stats else "?"
121
+ entry["deletions"] = stats["deletions"] if stats else "?"
122
+
123
+ return {
124
+ "current_branch": current_branch,
125
+ "base_ref": base_ref,
126
+ "merge_base": merge_base,
127
+ "shortstat": shortstat,
128
+ "files": name_status,
129
+ }
130
+
131
+
132
+ def print_markdown(context: dict[str, object]) -> None:
133
+ print("# Branch Change Context")
134
+ print()
135
+ print(f"- Current branch: `{context['current_branch']}`")
136
+ print(f"- Base ref: `{context['base_ref']}`")
137
+ print(f"- Merge base: `{context['merge_base']}`")
138
+ print(f"- Summary: {context['shortstat'] or 'No changes detected'}")
139
+ print()
140
+
141
+ files = context["files"]
142
+ if not files:
143
+ print("No changed files detected.")
144
+ return
145
+
146
+ print("| Status | Path | +/- |")
147
+ print("|--------|------|-----|")
148
+
149
+ for entry in files:
150
+ status = entry["status"]
151
+ path = entry["path"]
152
+ if entry["old_path"]:
153
+ path = f"{entry['old_path']} -> {entry['path']}"
154
+ delta = f"+{entry['additions']} / -{entry['deletions']}"
155
+ print(f"| `{status}` | `{path}` | {delta} |")
156
+
157
+
158
+ def main() -> int:
159
+ parser = argparse.ArgumentParser(
160
+ description="Resolve a base branch and print context for documenting branch changes."
161
+ )
162
+ parser.add_argument(
163
+ "--base",
164
+ help="Explicit base ref to compare against. Defaults to main/origin-main or master/origin-master fallback.",
165
+ )
166
+ parser.add_argument(
167
+ "--format",
168
+ choices=("markdown", "json"),
169
+ default="markdown",
170
+ help="Output format.",
171
+ )
172
+ args = parser.parse_args()
173
+
174
+ base_ref = resolve_base_ref(args.base)
175
+ context = build_context(base_ref)
176
+
177
+ if args.format == "json":
178
+ print(json.dumps(context, indent=2))
179
+ else:
180
+ print_markdown(context)
181
+
182
+ return 0
183
+
184
+
185
+ if __name__ == "__main__":
186
+ try:
187
+ raise SystemExit(main())
188
+ except subprocess.CalledProcessError as error:
189
+ sys.stderr.write(error.stderr or str(error))
190
+ raise SystemExit(error.returncode)
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: stylesheet-migration
3
+ description: Migrate Shopify Liquid `{% stylesheet %}` blocks into theme CSS assets using bundled Python scripts. Use when moving inline section or snippet stylesheet tags into `assets/*.css`, especially in `sections/` and `snippets/`. By default the scripts only process filenames that start with `td-`, creating `section-<source>.css` for files in `sections/` and `component-<source>.css` for files in `snippets/`, unless the caller specifies a different prefix or disables the prefix filter.
4
+ ---
5
+
6
+ # Stylesheet Migration
7
+
8
+ Run the bundled CLI from the theme root:
9
+
10
+ ```bash
11
+ python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --dry-run
12
+ ```
13
+
14
+ Default behavior
15
+ - Scans `sections/*.liquid` and `snippets/*.liquid`.
16
+ - Only considers filenames that start with `td-`.
17
+ - Extracts CSS from each `{% stylesheet %}...{% endstylesheet %}` block.
18
+ - Writes assets to `assets/section-<stem>.css` or `assets/component-<stem>.css`.
19
+ - Inserts `{{ '...' | asset_url | stylesheet_tag }}` at the top of the source file, below any leading doc comment, and removes all stylesheet blocks from the source file.
20
+
21
+ Common variants
22
+ - Different prefix: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --prefix promo-`
23
+ - Multiple prefixes: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --prefix td- --prefix promo-`
24
+ - No prefix filter: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --no-prefix-filter`
25
+ - Limit the scope to specific files or directories: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py sections/td-main-bundle-product.liquid snippets/`
26
+ - Overwrite an existing asset when its contents differ: `python3 .agents/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py --overwrite-assets`
27
+
28
+ Workflow
29
+ 1. Run with `--dry-run` first.
30
+ 2. Review the planned asset names and any conflicts.
31
+ 3. Re-run without `--dry-run` once the scope is correct.
32
+
33
+ Notes
34
+ - Files outside `sections/` and `snippets/` are ignored.
35
+ - Existing asset files are preserved unless `--overwrite-assets` is passed.
36
+ - If the Liquid file already includes the generated asset, the script removes the inline stylesheet block without inserting a duplicate include.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "Stylesheet Migration"
3
+ short_description: "Move Liquid stylesheet blocks into theme CSS assets"
4
+ default_prompt: "Use $stylesheet-migration to move `{% stylesheet %}` blocks from Shopify Liquid files into asset CSS files."
5
+ policy:
6
+ allow_implicit_invocation: true
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import re
6
+
7
+ DEFAULT_PREFIXES = ("td-",)
8
+ DEFAULT_SOURCE_DIRS = ("sections", "snippets")
9
+
10
+ STYLESHEET_BLOCK_RE = re.compile(
11
+ r"(?P<indent>^[ \t]*)\{%-?\s*stylesheet\s*-?%\}[ \t]*\r?\n?"
12
+ r"(?P<content>.*?)"
13
+ r"(?:(?:\r?\n)?(?P=indent))\{%-?\s*endstylesheet\s*-?%\}[ \t]*(?:\r?\n)?",
14
+ re.DOTALL | re.MULTILINE,
15
+ )
16
+ LIQUID_COMMENT_BLOCK_RE = re.compile(
17
+ r"\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\}",
18
+ re.DOTALL,
19
+ )
20
+ LEADING_COMMENT_RE = re.compile(
21
+ r"^(?P<comment>\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\})(?P<spacing>(?:\r?\n)*)",
22
+ re.DOTALL,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class MigrationPlan:
28
+ source_path: Path
29
+ asset_path: Path
30
+ asset_name: str
31
+ asset_content: str
32
+ updated_source: str
33
+ block_count: int
34
+ include_inserted: bool
35
+
36
+
37
+ class MigrationError(RuntimeError):
38
+ """Raised when a source file cannot be migrated safely."""
39
+
40
+
41
+ def read_text_preserve_newlines(path: Path) -> str:
42
+ with path.open("r", encoding="utf-8", newline="") as handle:
43
+ return handle.read()
44
+
45
+
46
+ def write_text_preserve_newlines(path: Path, content: str) -> None:
47
+ with path.open("w", encoding="utf-8", newline="") as handle:
48
+ handle.write(content)
49
+
50
+
51
+ def detect_newline(text: str) -> str:
52
+ return "\r\n" if "\r\n" in text else "\n"
53
+
54
+
55
+ def is_supported_source(root: Path, path: Path) -> bool:
56
+ try:
57
+ relative_path = path.relative_to(root)
58
+ except ValueError:
59
+ return False
60
+
61
+ return (
62
+ path.is_file()
63
+ and path.suffix == ".liquid"
64
+ and len(relative_path.parts) >= 2
65
+ and relative_path.parts[0] in DEFAULT_SOURCE_DIRS
66
+ )
67
+
68
+
69
+ def asset_name_for_source(root: Path, source_path: Path) -> str:
70
+ relative_path = source_path.relative_to(root)
71
+ source_dir = relative_path.parts[0]
72
+
73
+ if source_dir == "sections":
74
+ prefix = "section-"
75
+ elif source_dir == "snippets":
76
+ prefix = "component-"
77
+ else:
78
+ raise MigrationError(
79
+ f"{source_path} must live in sections/ or snippets/ to derive an asset name."
80
+ )
81
+
82
+ return f"{prefix}{source_path.stem}.css"
83
+
84
+
85
+ def build_asset_include(asset_name: str, indent: str = "") -> str:
86
+ return f"{indent}{{{{ '{asset_name}' | asset_url | stylesheet_tag }}}}"
87
+
88
+
89
+ def build_asset_include_pattern(asset_name: str) -> re.Pattern[str]:
90
+ return re.compile(
91
+ r"\{\{\-?\s*['\"]"
92
+ + re.escape(asset_name)
93
+ + r"['\"]\s*\|\s*asset_url\s*\|\s*stylesheet_tag(?:\s*:\s*[^}]*)?\s*\-?\}\}"
94
+ )
95
+
96
+
97
+ def normalize_stylesheet_content(content: str, newline: str) -> str:
98
+ normalized = content.replace("\r\n", "\n").strip("\n")
99
+ return normalized.replace("\n", newline)
100
+
101
+
102
+ def find_active_stylesheet_matches(source_text: str) -> list[re.Match[str]]:
103
+ comment_ranges = [
104
+ (match.start(), match.end()) for match in LIQUID_COMMENT_BLOCK_RE.finditer(source_text)
105
+ ]
106
+
107
+ return [
108
+ match
109
+ for match in STYLESHEET_BLOCK_RE.finditer(source_text)
110
+ if not any(
111
+ comment_start <= match.start() and match.end() <= comment_end
112
+ for comment_start, comment_end in comment_ranges
113
+ )
114
+ ]
115
+
116
+
117
+ def insert_include_at_top(source_text: str, asset_name: str, newline: str) -> str:
118
+ include = build_asset_include(asset_name)
119
+ match = LEADING_COMMENT_RE.match(source_text)
120
+
121
+ if match:
122
+ comment = match.group("comment")
123
+ body = source_text[match.end() :].lstrip("\r\n")
124
+ return f"{comment}{newline}{newline}{include}{newline}{newline}{body}" if body else (
125
+ f"{comment}{newline}{newline}{include}{newline}"
126
+ )
127
+
128
+ body = source_text.lstrip("\r\n")
129
+ return f"{include}{newline}{newline}{body}" if body else f"{include}{newline}"
130
+
131
+
132
+ def plan_migration(root: Path, source_path: Path) -> MigrationPlan | None:
133
+ source_text = read_text_preserve_newlines(source_path)
134
+ matches = find_active_stylesheet_matches(source_text)
135
+
136
+ if not matches:
137
+ return None
138
+
139
+ newline = detect_newline(source_text)
140
+ asset_name = asset_name_for_source(root, source_path)
141
+ asset_path = root / "assets" / asset_name
142
+ asset_include_exists = bool(build_asset_include_pattern(asset_name).search(source_text))
143
+
144
+ asset_chunks = [
145
+ normalize_stylesheet_content(match.group("content"), newline)
146
+ for match in matches
147
+ if match.group("content").strip()
148
+ ]
149
+ if not asset_chunks:
150
+ raise MigrationError(f"{source_path} contains only empty stylesheet blocks.")
151
+
152
+ updated_parts: list[str] = []
153
+ cursor = 0
154
+ include_inserted = False
155
+ keep_existing_include = asset_include_exists
156
+
157
+ for match in matches:
158
+ updated_parts.append(source_text[cursor : match.start()])
159
+ cursor = match.end()
160
+
161
+ updated_parts.append(source_text[cursor:])
162
+ updated_source = "".join(updated_parts)
163
+
164
+ if not keep_existing_include:
165
+ updated_source = insert_include_at_top(updated_source, asset_name, newline)
166
+ include_inserted = True
167
+
168
+ asset_content = (f"{newline}{newline}".join(asset_chunks)).rstrip("\r\n") + newline
169
+
170
+ return MigrationPlan(
171
+ source_path=source_path,
172
+ asset_path=asset_path,
173
+ asset_name=asset_name,
174
+ asset_content=asset_content,
175
+ updated_source=updated_source,
176
+ block_count=len(matches),
177
+ include_inserted=include_inserted,
178
+ )
179
+
180
+
181
+ def validate_migration(plan: MigrationPlan, overwrite_assets: bool = False) -> None:
182
+ if not plan.asset_path.exists():
183
+ return
184
+
185
+ existing_asset = read_text_preserve_newlines(plan.asset_path)
186
+ if existing_asset != plan.asset_content and not overwrite_assets:
187
+ raise MigrationError(
188
+ f"{plan.asset_path} already exists with different contents. "
189
+ "Re-run with --overwrite-assets to replace it."
190
+ )
191
+
192
+
193
+ def write_migration(plan: MigrationPlan, overwrite_assets: bool = False) -> None:
194
+ plan.asset_path.parent.mkdir(parents=True, exist_ok=True)
195
+ validate_migration(plan, overwrite_assets=overwrite_assets)
196
+
197
+ if plan.asset_path.exists():
198
+ existing_asset = read_text_preserve_newlines(plan.asset_path)
199
+ if existing_asset != plan.asset_content:
200
+ write_text_preserve_newlines(plan.asset_path, plan.asset_content)
201
+ else:
202
+ write_text_preserve_newlines(plan.asset_path, plan.asset_content)
203
+
204
+ write_text_preserve_newlines(plan.source_path, plan.updated_source)
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from liquid_stylesheet_migrator import (
9
+ DEFAULT_PREFIXES,
10
+ DEFAULT_SOURCE_DIRS,
11
+ MigrationError,
12
+ is_supported_source,
13
+ plan_migration,
14
+ validate_migration,
15
+ write_migration,
16
+ )
17
+
18
+
19
+ def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(
21
+ description=(
22
+ "Move Shopify Liquid {% stylesheet %} blocks into assets/*.css files. "
23
+ "By default only files whose names start with td- are processed."
24
+ )
25
+ )
26
+ parser.add_argument(
27
+ "paths",
28
+ nargs="*",
29
+ help="Optional files or directories to scan. Defaults to sections/ and snippets/.",
30
+ )
31
+ parser.add_argument(
32
+ "--root",
33
+ default=".",
34
+ help="Theme root directory. Defaults to the current working directory.",
35
+ )
36
+ parser.add_argument(
37
+ "--prefix",
38
+ action="append",
39
+ dest="prefixes",
40
+ help="Filename prefix to include. May be passed multiple times. Defaults to td-.",
41
+ )
42
+ parser.add_argument(
43
+ "--no-prefix-filter",
44
+ action="store_true",
45
+ help="Process all supported Liquid files in scope, regardless of filename prefix.",
46
+ )
47
+ parser.add_argument(
48
+ "--overwrite-assets",
49
+ action="store_true",
50
+ help="Overwrite an existing generated asset when its contents differ.",
51
+ )
52
+ parser.add_argument(
53
+ "--dry-run",
54
+ action="store_true",
55
+ help="Print the planned migrations without writing any files.",
56
+ )
57
+ return parser.parse_args()
58
+
59
+
60
+ def resolve_path(root: Path, raw_path: str) -> Path:
61
+ path = Path(raw_path)
62
+ if not path.is_absolute():
63
+ path = root / path
64
+ return path.resolve()
65
+
66
+
67
+ def collect_source_paths(root: Path, raw_paths: list[str]) -> tuple[list[Path], list[str]]:
68
+ discovered: list[Path] = []
69
+ errors: list[str] = []
70
+
71
+ if raw_paths:
72
+ candidates = [resolve_path(root, raw_path) for raw_path in raw_paths]
73
+ else:
74
+ candidates = [root / source_dir for source_dir in DEFAULT_SOURCE_DIRS]
75
+
76
+ for candidate in candidates:
77
+ if not candidate.exists():
78
+ errors.append(f"{candidate} does not exist.")
79
+ continue
80
+
81
+ if candidate.is_dir():
82
+ discovered.extend(sorted(candidate.rglob("*.liquid")))
83
+ elif candidate.is_file():
84
+ discovered.append(candidate)
85
+ else:
86
+ errors.append(f"{candidate} is not a file or directory.")
87
+
88
+ unique_paths = sorted({path.resolve() for path in discovered})
89
+ return unique_paths, errors
90
+
91
+
92
+ def matches_prefix(path: Path, prefixes: tuple[str, ...], no_prefix_filter: bool) -> bool:
93
+ return no_prefix_filter or any(path.name.startswith(prefix) for prefix in prefixes)
94
+
95
+
96
+ def summarize_count(count: int, singular: str, plural: str | None = None) -> str:
97
+ if count == 1:
98
+ return f"1 {singular}"
99
+ return f"{count} {plural or singular + 's'}"
100
+
101
+
102
+ def main() -> int:
103
+ args = parse_args()
104
+ root = Path(args.root).resolve()
105
+ prefixes = tuple(args.prefixes or DEFAULT_PREFIXES)
106
+
107
+ source_paths, collection_errors = collect_source_paths(root, args.paths)
108
+ for message in collection_errors:
109
+ print(f"ERROR {message}", file=sys.stderr)
110
+
111
+ if not source_paths:
112
+ if collection_errors:
113
+ return 1
114
+ print("No Liquid files found in scope.", file=sys.stderr)
115
+ return 1
116
+
117
+ eligible_paths = [
118
+ path
119
+ for path in source_paths
120
+ if is_supported_source(root, path) and matches_prefix(path, prefixes, args.no_prefix_filter)
121
+ ]
122
+
123
+ ignored_count = len(source_paths) - len(eligible_paths)
124
+ migrated_count = 0
125
+ no_block_count = 0
126
+ error_count = len(collection_errors)
127
+
128
+ for source_path in eligible_paths:
129
+ relative_source = source_path.relative_to(root)
130
+ try:
131
+ plan = plan_migration(root, source_path)
132
+ if plan is None:
133
+ no_block_count += 1
134
+ continue
135
+
136
+ relative_asset = plan.asset_path.relative_to(root)
137
+ include_note = "reused existing include" if not plan.include_inserted else "inserted include"
138
+
139
+ if args.dry_run:
140
+ validate_migration(plan, overwrite_assets=args.overwrite_assets)
141
+ action = "Would migrate"
142
+ else:
143
+ write_migration(plan, overwrite_assets=args.overwrite_assets)
144
+ action = "Migrated"
145
+
146
+ print(
147
+ f"{action} {relative_source} -> {relative_asset} "
148
+ f"({summarize_count(plan.block_count, 'block')}; {include_note})"
149
+ )
150
+
151
+ migrated_count += 1
152
+ except MigrationError as exc:
153
+ print(f"ERROR {relative_source}: {exc}", file=sys.stderr)
154
+ error_count += 1
155
+
156
+ summary = [
157
+ summarize_count(len(source_paths), "candidate"),
158
+ summarize_count(len(eligible_paths), "eligible file"),
159
+ summarize_count(migrated_count, "migration"),
160
+ summarize_count(no_block_count, "file without a stylesheet block", "files without stylesheet blocks"),
161
+ ]
162
+ if ignored_count:
163
+ summary.append(summarize_count(ignored_count, "ignored file"))
164
+ if error_count:
165
+ summary.append(summarize_count(error_count, "error"))
166
+
167
+ print("Summary: " + ", ".join(summary))
168
+ return 1 if error_count else 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())