26bf26fc20
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
606 lines
23 KiB
Python
606 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Lint Kandev harness files.
|
|
|
|
Usage:
|
|
python3 .github/scripts/lint-harness-files.py --all
|
|
python3 .github/scripts/lint-harness-files.py AGENTS.md .agents/skills/fix/SKILL.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError: # Python < 3.11
|
|
try:
|
|
import tomli as tomllib
|
|
except ModuleNotFoundError:
|
|
tomllib = None
|
|
|
|
|
|
DEFAULT_RULES_BY_KIND = {
|
|
"agent": {
|
|
"agent-line-limit": {"limit": 300},
|
|
},
|
|
"skill": {
|
|
"skill-line-limit": {"limit": 500},
|
|
"description-word-limit": {"limit": 600},
|
|
},
|
|
"reference": {
|
|
"reference-line-limit": {"limit": 400},
|
|
},
|
|
"role-agent": {
|
|
"role-agent-line-limit": {"limit": 300},
|
|
"description-word-limit": {"limit": 600},
|
|
},
|
|
"command": {
|
|
"command-line-limit": {"limit": 300},
|
|
"description-word-limit": {"limit": 600},
|
|
},
|
|
"cursor-rule": {
|
|
"cursor-rule-line-limit": {"limit": 300},
|
|
"description-word-limit": {"limit": 600},
|
|
},
|
|
"config": {
|
|
"config-line-limit": {"limit": 300},
|
|
},
|
|
}
|
|
|
|
LINE_LIMIT_RULES = {
|
|
"agent-line-limit",
|
|
"skill-line-limit",
|
|
"reference-line-limit",
|
|
"role-agent-line-limit",
|
|
"command-line-limit",
|
|
"cursor-rule-line-limit",
|
|
"config-line-limit",
|
|
}
|
|
|
|
OPT_IN_RULES = {
|
|
"description-when",
|
|
"no-at-dot-slash",
|
|
"no-emoji",
|
|
"no-em-dash",
|
|
}
|
|
|
|
OPT_IN_RULES_BY_KIND = {
|
|
"description-when": {"skill", "role-agent", "command", "cursor-rule"},
|
|
"no-at-dot-slash": set(DEFAULT_RULES_BY_KIND),
|
|
"no-emoji": set(DEFAULT_RULES_BY_KIND),
|
|
"no-em-dash": set(DEFAULT_RULES_BY_KIND),
|
|
}
|
|
|
|
YAML_BLOCK_SCALAR_MARKERS = {">", "|", ">-", "|-", ">+", "|+"}
|
|
|
|
EMOJI_RANGES = (
|
|
(0x2600, 0x27BF),
|
|
(0x1F000, 0x1FAFF),
|
|
)
|
|
|
|
|
|
class Violation:
|
|
def __init__(self, rule: str, path: Path, line: int, msg: str) -> None:
|
|
self.rule = rule
|
|
self.path = path
|
|
self.line = line
|
|
self.msg = msg
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Violation(rule={self.rule!r}, path={str(self.path)!r}, line={self.line!r}, msg={self.msg!r})"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Lint Kandev harness files.")
|
|
parser.add_argument("targets", nargs="*", help="Files or directories to lint")
|
|
parser.add_argument("--all", action="store_true", help="Lint every tracked harness file")
|
|
parser.add_argument("--disable", action="append", default=[], help="Disable one rule by id")
|
|
parser.add_argument("--enable", action="append", default=[], help="Enable one opt-in rule by id")
|
|
args = parser.parse_args()
|
|
|
|
if not args.all and not args.targets:
|
|
parser.print_usage(sys.stderr)
|
|
return 2
|
|
|
|
cwd = Path.cwd()
|
|
try:
|
|
paths = discover_all(cwd) if args.all else discover_targets([Path(target) for target in args.targets], cwd)
|
|
except RuntimeError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
|
|
disabled = set(args.disable)
|
|
enabled = set(args.enable)
|
|
unknown_enabled = sorted(enabled - OPT_IN_RULES)
|
|
if unknown_enabled:
|
|
print(f"unknown opt-in rule(s): {', '.join(unknown_enabled)}", file=sys.stderr)
|
|
return 2
|
|
violations = []
|
|
linted = 0
|
|
|
|
try:
|
|
for path in paths:
|
|
if not path.exists():
|
|
continue
|
|
kind = classify_path(path)
|
|
if kind == "unknown":
|
|
continue
|
|
linted += 1
|
|
rules = dict(DEFAULT_RULES_BY_KIND.get(kind, {}))
|
|
for rule in enabled:
|
|
if kind not in OPT_IN_RULES_BY_KIND[rule]:
|
|
continue
|
|
rules.setdefault(rule, {})
|
|
violations.extend(lint_path(path, kind, rules=rules, disabled=disabled))
|
|
except RuntimeError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
|
|
for violation in violations:
|
|
print(format_violation(violation, cwd))
|
|
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
print(format_github_annotation(violation, cwd))
|
|
|
|
if violations:
|
|
print(f"\n{len(violations)} harness lint violation(s) found.", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"All {linted} harness file(s) passed.")
|
|
return 0
|
|
|
|
|
|
def discover_all(cwd: Path) -> list[Path]:
|
|
result = subprocess.run(
|
|
["git", "ls-files", "-z"],
|
|
cwd=cwd,
|
|
text=False,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
|
raise RuntimeError(stderr or "git ls-files failed")
|
|
names = [name.decode("utf-8", errors="replace") for name in result.stdout.split(b"\0") if name]
|
|
return [cwd / name for name in names]
|
|
|
|
|
|
def discover_targets(targets: list[Path], cwd: Path) -> list[Path]:
|
|
paths = []
|
|
for target in targets:
|
|
path = target if target.is_absolute() else cwd / target
|
|
if not path.exists():
|
|
continue
|
|
if path.is_dir() and not path.is_symlink():
|
|
paths.extend(walk_without_symlink_dirs(path))
|
|
continue
|
|
paths.append(path)
|
|
return sorted(paths)
|
|
|
|
|
|
def walk_without_symlink_dirs(root: Path) -> list[Path]:
|
|
paths = []
|
|
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
current = Path(dirpath)
|
|
dirnames[:] = [name for name in dirnames if not (current / name).is_symlink()]
|
|
for filename in filenames:
|
|
paths.append(current / filename)
|
|
return paths
|
|
|
|
|
|
def classify_path(path: Path) -> str:
|
|
name = path.name
|
|
if name in {"AGENTS.md", "CLAUDE.md", ".cursorrules"}:
|
|
return "agent"
|
|
|
|
parts = normalized_parts(path)
|
|
if (
|
|
has_subpath(parts, [".agents", "skills"])
|
|
or has_subpath(parts, [".augment", "skills"])
|
|
or has_subpath(parts, [".claude", "skills"])
|
|
or has_subpath(parts, [".cursor", "skills"])
|
|
or has_subpath(parts, [".opencode", "skills"])
|
|
):
|
|
if name == "SKILL.md":
|
|
return "skill"
|
|
if name.endswith(".md"):
|
|
return "reference"
|
|
|
|
if has_subpath(parts, ["apps", "backend", "internal", "office", "configloader", "skills"]):
|
|
if name == "SKILL.md":
|
|
return "skill"
|
|
if name.endswith(".md"):
|
|
return "reference"
|
|
|
|
if has_subpath(parts, [".agents", "agents"]) and name.endswith(".md"):
|
|
return "role-agent"
|
|
if has_subpath(parts, [".opencode", "agents"]) and name.endswith(".md"):
|
|
return "role-agent"
|
|
if has_subpath(parts, [".claude", "agents"]) and name.endswith(".md"):
|
|
return "role-agent"
|
|
if has_subpath(parts, [".codex", "agents"]) and name.endswith(".toml"):
|
|
return "role-agent"
|
|
if has_subpath(parts, [".codex"]) and name == "config.toml":
|
|
return "config"
|
|
if has_subpath(parts, [".claude"]) and name == "settings.json":
|
|
return "config"
|
|
if has_subpath(parts, [".claude", "commands"]) and name.endswith(".md"):
|
|
return "command"
|
|
if has_subpath(parts, [".claude", "rules"]) and name.endswith(".md"):
|
|
return "cursor-rule"
|
|
if has_subpath(parts, [".cursor", "rules"]) and name.endswith(".mdc"):
|
|
return "cursor-rule"
|
|
|
|
return "unknown"
|
|
|
|
|
|
def normalized_parts(path: Path) -> list[str]:
|
|
return [part for part in path.as_posix().split("/") if part not in {"", "."}]
|
|
|
|
|
|
def has_subpath(parts: list[str], needle: list[str]) -> bool:
|
|
if len(parts) < len(needle):
|
|
return False
|
|
for index in range(0, len(parts) - len(needle) + 1):
|
|
if parts[index : index + len(needle)] == needle:
|
|
return True
|
|
return False
|
|
|
|
|
|
def lint_path(
|
|
path: Path,
|
|
kind: str | None = None,
|
|
rules: dict[str, dict[str, int]] | None = None,
|
|
disabled: set[str] | None = None,
|
|
) -> list[Violation]:
|
|
kind = kind or classify_path(path)
|
|
if kind == "unknown":
|
|
return []
|
|
rules = rules if rules is not None else DEFAULT_RULES_BY_KIND.get(kind, {})
|
|
disabled = disabled or set()
|
|
text = path.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
|
|
violations = []
|
|
for rule, options in rules.items():
|
|
if rule in disabled:
|
|
continue
|
|
if rule in OPT_IN_RULES_BY_KIND and kind not in OPT_IN_RULES_BY_KIND[rule]:
|
|
continue
|
|
if rule in LINE_LIMIT_RULES:
|
|
violations.extend(check_line_limit(path, rule, lines, int(options.get("limit", 0))))
|
|
elif rule == "description-word-limit":
|
|
violations.extend(check_description_word_limit(path, text, int(options.get("limit", 0))))
|
|
elif rule == "no-emoji":
|
|
violations.extend(check_no_emoji(path, lines))
|
|
elif rule == "no-em-dash":
|
|
violations.extend(check_no_em_dash(path, lines))
|
|
elif rule == "no-at-dot-slash":
|
|
violations.extend(check_no_at_dot_slash(path, lines))
|
|
elif rule == "description-when":
|
|
violations.extend(check_description_when(path, text))
|
|
return violations
|
|
|
|
|
|
def check_line_limit(path: Path, rule: str, lines: list[str], limit: int) -> list[Violation]:
|
|
if limit <= 0:
|
|
return []
|
|
count = len(lines)
|
|
if count <= limit:
|
|
return []
|
|
return [Violation(rule, path, 0, line_limit_message(rule, count, limit))]
|
|
|
|
|
|
def line_limit_message(rule: str, count: int, limit: int) -> str:
|
|
prefix = f"file is {count} lines (limit {limit}, over by {count - limit})."
|
|
target = f"target: {limit} lines or fewer."
|
|
if rule == "agent-line-limit":
|
|
return (
|
|
f"{prefix} why: this file is reloaded into the agent's context on every turn; every line here is paid for "
|
|
"in every conversation. fix: trim down to the essential rules and routing signals. move detailed examples, "
|
|
f"command catalogues, or reference material into a sibling file (e.g. REFERENCE.md, EXAMPLES.md) and link "
|
|
f"to it from this file. {target}"
|
|
)
|
|
if rule == "skill-line-limit":
|
|
return (
|
|
f"{prefix} why: when this skill triggers, the body loads into context and competes with the running "
|
|
"conversation. fix: keep SKILL.md focused on (1) when to trigger and (2) the core steps. move long examples, "
|
|
f"command catalogues, troubleshooting, or reference material into sibling files (REFERENCE.md, EXAMPLES.md, "
|
|
f"examples/*.md) and link them from SKILL.md. {target}"
|
|
)
|
|
if rule == "reference-line-limit":
|
|
return (
|
|
f"{prefix} why: oversize reference files signal a monolithic skill that is hard to navigate and slow to load "
|
|
"on demand. fix: split this file into multiple focused files in the same skill directory (one per topic) "
|
|
f"and update the SKILL.md links. {target}"
|
|
)
|
|
if rule == "role-agent-line-limit":
|
|
return (
|
|
f"{prefix} why: agent definitions are loaded into model/router context when selecting or spawning specialist "
|
|
"agents. fix: keep the file focused on role, tool constraints, and workflow; move long examples or command "
|
|
f"catalogues into referenced files. {target}"
|
|
)
|
|
if rule == "command-line-limit":
|
|
return (
|
|
f"{prefix} why: slash-command prompts are loaded when invoked and compete with the active task context. "
|
|
"fix: keep the command compact and move long examples, command catalogues, or rationale into referenced "
|
|
f"files. {target}"
|
|
)
|
|
if rule == "cursor-rule-line-limit":
|
|
return (
|
|
f"{prefix} why: Cursor rules are routing and style context that should stay concise. fix: move detailed "
|
|
f"examples or long reference material into sibling files and link to them. {target}"
|
|
)
|
|
if rule == "config-line-limit":
|
|
return (
|
|
f"{prefix} why: agent config files are read when spawning or routing agent runs, and bloated config obscures "
|
|
"the active model, permission, and tool choices. fix: keep config files to the settings the runtime needs; "
|
|
f"move rationale, examples, or setup notes into a referenced markdown file. {target}"
|
|
)
|
|
return f"{prefix} {target}"
|
|
|
|
|
|
def check_description_word_limit(path: Path, text: str, limit: int) -> list[Violation]:
|
|
if limit <= 0:
|
|
return []
|
|
description = extract_description(path, text)
|
|
if not description:
|
|
return []
|
|
count = len(description.split())
|
|
if count <= limit:
|
|
return []
|
|
message = (
|
|
f"description is {count} words (limit {limit}, over by {count - limit}). why: harness descriptions are loaded "
|
|
"into routing context before the body is selected, and long descriptions compete with discovery of sibling "
|
|
"artifacts. fix: keep the description to a single routing sentence ('Use when X. Do Y.') and move details, "
|
|
f"examples, command lists, or rationale into the body or a referenced file. target: {limit} words or fewer."
|
|
)
|
|
return [Violation("description-word-limit", path, 1, message)]
|
|
|
|
|
|
def extract_description(path: Path, text: str) -> str | None:
|
|
if path.suffix == ".toml":
|
|
return extract_toml_description(text)
|
|
status, description = extract_yaml_description(text.splitlines())
|
|
if status != "ok":
|
|
return None
|
|
return description
|
|
|
|
|
|
def extract_yaml_description(lines: list[str]) -> tuple[str, str | None]:
|
|
if not lines or lines[0].strip() != "---":
|
|
return "missing", None
|
|
close_index = None
|
|
for index in range(1, len(lines)):
|
|
if lines[index] == "---":
|
|
close_index = index
|
|
break
|
|
if close_index is None:
|
|
return "unclosed", None
|
|
|
|
frontmatter = lines[1:close_index]
|
|
for index, line in enumerate(frontmatter):
|
|
if line.startswith((" ", "\t")):
|
|
continue
|
|
if not line.startswith("description:"):
|
|
continue
|
|
value = line[len("description:") :].strip()
|
|
chunks = [strip_yaml_scalar(value)] if value and value not in YAML_BLOCK_SCALAR_MARKERS else []
|
|
for continuation in frontmatter[index + 1 :]:
|
|
if not continuation.startswith((" ", "\t")):
|
|
break
|
|
stripped = continuation.strip()
|
|
if stripped:
|
|
chunks.append(strip_yaml_scalar(stripped))
|
|
return ("empty" if not chunks else "ok"), " ".join(chunks).strip()
|
|
return "ok", None
|
|
|
|
|
|
def strip_yaml_scalar(value: str) -> str:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def extract_toml_description(text: str) -> str | None:
|
|
if tomllib is None:
|
|
raise RuntimeError(
|
|
"TOML harness files require Python 3.11+ or the 'tomli' package. "
|
|
"Install tomli or run this linter with Python 3.11+."
|
|
)
|
|
try:
|
|
data = tomllib.loads(text)
|
|
except tomllib.TOMLDecodeError as exc:
|
|
raise RuntimeError(f"invalid TOML in harness file: {exc}") from exc
|
|
description = data.get("description")
|
|
if not isinstance(description, str):
|
|
return None
|
|
return description.strip()
|
|
|
|
|
|
def check_no_emoji(path: Path, lines: list[str]) -> list[Violation]:
|
|
violations = []
|
|
for line_number, line in enumerate(lines, start=1):
|
|
for column, char in enumerate(line, start=1):
|
|
if is_emoji(char):
|
|
message = (
|
|
f'emoji "{char}" at column {column}. why: emojis spend context tokens with no semantic value for '
|
|
"the model and are banned by repo style in harness guidance files. fix: remove the emoji entirely, "
|
|
"or replace it with a short text label (e.g. [OK], [WARN], [FAIL])."
|
|
)
|
|
violations.append(Violation("no-emoji", path, line_number, message))
|
|
break
|
|
return violations
|
|
|
|
|
|
def is_emoji(char: str) -> bool:
|
|
codepoint = ord(char)
|
|
return any(start <= codepoint <= end for start, end in EMOJI_RANGES)
|
|
|
|
|
|
def check_no_em_dash(path: Path, lines: list[str]) -> list[Violation]:
|
|
violations = []
|
|
for line_number, line in enumerate(lines, start=1):
|
|
index = line.find("\u2014")
|
|
if index == -1:
|
|
continue
|
|
column = index + 1
|
|
message = (
|
|
f"em-dash (U+2014) at column {column}. why: em-dashes are a stylistic tell of AI-generated prose; repo "
|
|
"style requires plain ASCII dashes in harness files. fix: replace U+2014 with ' - ' (space-hyphen-space) "
|
|
"or rephrase the sentence to remove the dash. search for the literal em-dash character (U+2014) in this "
|
|
"file to locate every occurrence."
|
|
)
|
|
violations.append(Violation("no-em-dash", path, line_number, message))
|
|
return violations
|
|
|
|
|
|
def check_no_at_dot_slash(path: Path, lines: list[str]) -> list[Violation]:
|
|
violations = []
|
|
for line_number, line in enumerate(lines, start=1):
|
|
index = line.find("@./")
|
|
if index == -1:
|
|
continue
|
|
column = index + 1
|
|
message = (
|
|
f"`@./` reference at column {column}. why: non-standard import/path pattern that confuses "
|
|
"harness loaders and is not documented behaviour. fix: drop the `@./` prefix. example: `@./SKILL.md` "
|
|
"becomes `./SKILL.md` or `SKILL.md`; `@./examples/foo.sh` becomes `examples/foo.sh`."
|
|
)
|
|
violations.append(Violation("no-at-dot-slash", path, line_number, message))
|
|
return violations
|
|
|
|
|
|
def check_description_when(path: Path, text: str) -> list[Violation]:
|
|
if path.suffix == ".toml":
|
|
return check_toml_description_when(path, text)
|
|
return check_yaml_description_when(path, text)
|
|
|
|
|
|
def check_yaml_description_when(path: Path, text: str) -> list[Violation]:
|
|
lines = text.splitlines()
|
|
status, description = extract_yaml_description(lines)
|
|
if status == "missing":
|
|
return [
|
|
Violation(
|
|
"description-when",
|
|
path,
|
|
1,
|
|
"file is missing YAML frontmatter (`--- ... ---` block). why: harness artifacts are discovered via "
|
|
"their frontmatter `name` and `description` fields; without a frontmatter block the router cannot "
|
|
"trigger the artifact. fix: add a YAML block at the very top of the file: `---` on its own line, then "
|
|
"`name: <kebab-case-skill-name>`, then `description: Use when <concrete trigger>. <one-line outcome>.`, "
|
|
"then a closing `---` line.",
|
|
)
|
|
]
|
|
if status == "unclosed":
|
|
return [
|
|
Violation(
|
|
"description-when",
|
|
path,
|
|
1,
|
|
"file frontmatter opens with `---` but never closes. why: a malformed frontmatter block is "
|
|
"unparseable and the artifact becomes invisible to the router. fix: add a `---` line on its own "
|
|
"(no leading whitespace) after the last frontmatter field to close the block.",
|
|
)
|
|
]
|
|
if status == "empty":
|
|
return [
|
|
Violation(
|
|
"description-when",
|
|
path,
|
|
1,
|
|
"file frontmatter has an empty `description:` field. why: the description is the primary signal the "
|
|
"router uses to decide whether this artifact applies to the user's request. fix: fill in "
|
|
"`description: Use when <concrete trigger>. <one-line outcome>.` between the opening and closing "
|
|
"--- fences.",
|
|
)
|
|
]
|
|
if not description:
|
|
return [
|
|
Violation(
|
|
"description-when",
|
|
path,
|
|
1,
|
|
"file frontmatter has no `description:` field. why: the description is the primary signal the "
|
|
"router uses to decide whether this artifact applies to the user's request. fix: add `description: "
|
|
"Use when <concrete trigger>. <one-line outcome>.` between the opening and closing `---` fences. "
|
|
"example: `description: Use when the user says 'create a branch' or references a CLIP-XXX ticket.`",
|
|
)
|
|
]
|
|
if not has_routing_phrase(description):
|
|
return [description_routing_violation(path)]
|
|
return []
|
|
|
|
|
|
def check_toml_description_when(path: Path, text: str) -> list[Violation]:
|
|
description = extract_toml_description(text)
|
|
if not description:
|
|
return [
|
|
Violation(
|
|
"description-when",
|
|
path,
|
|
1,
|
|
'agent TOML has no `description = "..."` field. why: the description is the primary router signal '
|
|
"for selecting this specialist agent. fix: add a concise `description = \"Use when ...\"` entry with "
|
|
"concrete task types or user phrases.",
|
|
)
|
|
]
|
|
if not has_routing_phrase(description):
|
|
return [description_routing_violation(path)]
|
|
return []
|
|
|
|
|
|
def has_routing_phrase(description: str) -> bool:
|
|
# Before enabling description-when by default, update artifacts that currently
|
|
# use looser phrasing like "Useful for ..." or incidental "... when ...".
|
|
return re.search(r"\b(use when|use for|trigger on|trigger when)\b", description, re.IGNORECASE) is not None
|
|
|
|
|
|
def description_routing_violation(path: Path) -> Violation:
|
|
return Violation(
|
|
"description-when",
|
|
path,
|
|
1,
|
|
'description lacks a routing phrase. why: the model needs an explicit trigger signal ("when" / '
|
|
'"use for") to pick this skill over siblings; vague descriptions cause missed triggers or wrong routing. '
|
|
'fix: start the description with one of "Use when ...", "Use for ...", or "Trigger on ..." followed by '
|
|
'concrete user phrases, file paths, or task types. example: "Use when the user says \'fix the bug\' or '
|
|
'\'X is broken\'."',
|
|
)
|
|
|
|
|
|
def format_violation(violation: Violation, cwd: Path) -> str:
|
|
path = display_path(violation.path, cwd)
|
|
if violation.line > 0:
|
|
return f"{path}:{violation.line}: [{violation.rule}] {violation.msg}"
|
|
return f"{path}: [{violation.rule}] {violation.msg}"
|
|
|
|
|
|
def format_github_annotation(violation: Violation, cwd: Path) -> str:
|
|
path = display_path(violation.path, cwd)
|
|
location = f"file={path}"
|
|
if violation.line > 0:
|
|
location += f",line={violation.line}"
|
|
return f"::error {location}::{escape_github_message(f'[{violation.rule}] {violation.msg}')}"
|
|
|
|
|
|
def display_path(path: Path, cwd: Path) -> str:
|
|
try:
|
|
return path.resolve().relative_to(cwd.resolve()).as_posix()
|
|
except ValueError:
|
|
return path.as_posix()
|
|
|
|
|
|
def escape_github_message(message: str) -> str:
|
|
return message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|