feat(ci): lint skills, refs and installer drift (#15)
This commit was merged in pull request #15.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck jq
|
||||
python3 -m pip install --break-system-packages ruff
|
||||
|
||||
# LINT_STRICT makes a missing tool a failure instead of a skip: a runner
|
||||
# image that quietly drops shellcheck would otherwise report green.
|
||||
- name: Lint
|
||||
run: LINT_STRICT=1 LINT_NO_NIX=1 bin/lint.sh
|
||||
|
||||
# Separate job: installing nix costs more than every other check together,
|
||||
# and a failure here should not hide the lint results.
|
||||
nix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: "experimental-features = nix-command flakes"
|
||||
- run: nix flake check --no-write-lock-file
|
||||
@@ -0,0 +1,20 @@
|
||||
name: vendored
|
||||
|
||||
# Weekly, not per-PR: this reaches out to every upstream repo, and a vendored
|
||||
# skill being a release behind is not a reason to block a change.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check vendored skills against upstream
|
||||
run: bin/check-vendored.sh | tee out.txt
|
||||
# check-vendored.sh always exits 0 (it is a report, and an unreachable
|
||||
# remote is not a failure). Turn actual drift into a red run here.
|
||||
- name: Fail if behind
|
||||
run: '! grep -q "behind upstream" out.txt'
|
||||
@@ -12,6 +12,7 @@ claude-md/ # shared instruction fragments — imported by entry files, concate
|
||||
entry/ # entry files: ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md
|
||||
systemd/ # user timers: weekly review + hour log — one machine only, see below
|
||||
bin/link.sh # bootstrap symlinks + generated AGENTS.md for non-Nix machines
|
||||
bin/lint.sh # every check CI runs — see below
|
||||
nix/home.nix # home-manager module for NixOS machines
|
||||
flake.nix # exposes homeModules.default
|
||||
```
|
||||
@@ -267,6 +268,24 @@ re-read from the API — nothing in it is acted on directly.
|
||||
|
||||
Drop a new `skills/<name>/SKILL.md` (+ optional `scripts/`, `references/`, `assets/`). Commit. Non-Nix: re-run `bin/link.sh`. Nix: rebuild.
|
||||
|
||||
## Lint
|
||||
|
||||
`bin/lint.sh` runs what CI runs. Missing tools are skipped with a note; CI sets `LINT_STRICT=1` so a tool absent from the runner fails instead of passing as green.
|
||||
|
||||
Generic checks: `shellcheck`, `ruff` (config in `ruff.toml`), `python3 -m compileall`, `jq` on every JSON file, `node --check`, `nix flake check`.
|
||||
|
||||
Repo-specific ones live in `bin/lint-repo.py` (stdlib only, no install needed):
|
||||
|
||||
- **Skill frontmatter** — `name` matches the directory, names are unique, `description` is non-empty, no unknown keys. A typo'd key is ignored silently by every tool that reads it.
|
||||
- **Internal paths** — every `<skills-root>/…` reference, repo-relative path and relative markdown link in a tracked file points at something that exists.
|
||||
- **Installer drift** — `bin/link.sh` and `nix/home.nix` install the same set of files. Expected divergences are listed in the script with the reason.
|
||||
- **Entry imports** — every `@~/.claude/x.md` in an entry file is something both installers actually create.
|
||||
- **Unit paths** — `ExecStart` targets in `systemd/*.service` and `nix/home.nix` exist in the repo.
|
||||
|
||||
Vendored skills are excluded from all of it.
|
||||
|
||||
`bin/check-vendored.sh` is not part of this — it needs network and runs weekly in its own workflow.
|
||||
|
||||
## Skills
|
||||
|
||||
| skill | what |
|
||||
|
||||
+13
-1
@@ -14,6 +14,7 @@ CODEX_SKILLS="$HOME/.agents/skills"
|
||||
CLAUDE_CMDS="$HOME/.claude/commands" # commands are Claude-only; Codex ignores
|
||||
OPENCODE_CMDS="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands"
|
||||
CLAUDE_HOOKS="$HOME/.claude/hooks" # hooks are Claude-only
|
||||
CLAUDE_SCRIPTS="$HOME/.claude/scripts" # referenced by hook commands in settings.json
|
||||
CLAUDE_HOME="$HOME/.claude" # CLAUDE.md fragments, pulled in via @name.md
|
||||
CLAUDE_RULES="$HOME/.claude/rules" # path-scoped rules
|
||||
CODEX_HOME="$HOME/.codex" # Codex global config root
|
||||
@@ -61,7 +62,7 @@ gen() { # gen <dst> <fragment...> — writes a generated (concatenated) file
|
||||
|
||||
GEN_MARK="<!-- generated by agent-skills/bin/link.sh — edit fragments, re-run -->"
|
||||
|
||||
mkdir -p "$CLAUDE_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" "$CLAUDE_HOOKS" "$CLAUDE_RULES" "$CODEX_HOME" "$PI_HOME" "$OPENCODE_CMDS"
|
||||
mkdir -p "$CLAUDE_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" "$CLAUDE_HOOKS" "$CLAUDE_SCRIPTS" "$CLAUDE_RULES" "$CODEX_HOME" "$PI_HOME" "$OPENCODE_CMDS"
|
||||
|
||||
for d in "$REPO"/skills/*/; do
|
||||
name="$(basename "$d")"
|
||||
@@ -80,6 +81,13 @@ for f in "$REPO"/hooks/*.py "$REPO"/hooks/*.sh; do
|
||||
link "$f" "$CLAUDE_HOOKS/$(basename "$f")"
|
||||
done
|
||||
|
||||
# Hook commands in settings.json call these by absolute path, so they have to
|
||||
# exist under ~/.claude/scripts on every machine.
|
||||
for f in "$REPO"/scripts/*; do
|
||||
[ -e "$f" ] || continue
|
||||
link "$f" "$CLAUDE_SCRIPTS/$(basename "$f")"
|
||||
done
|
||||
|
||||
# code-comments.md is path-scoped and belongs in rules/, not here — linking it
|
||||
# into ~/.claude/ as well would load it unconditionally and defeat the scoping.
|
||||
# opencode-header.md is opencode-only (baked into its generated AGENTS.md).
|
||||
@@ -90,6 +98,10 @@ for f in "$REPO"/claude-md/*.md; do
|
||||
link "$f" "$CLAUDE_HOME/$(basename "$f")"
|
||||
done
|
||||
|
||||
# Per-machine section. entry/CLAUDE.md @imports it unconditionally, so it has
|
||||
# to resolve even on a box with no profile of its own (MACHINE=default).
|
||||
link "$REPO/claude-md/machines/$MACHINE.md" "$CLAUDE_HOME/machine.md"
|
||||
|
||||
# Path-scoped rules load only when Claude reads a matching file.
|
||||
link "$REPO/claude-md/code-comments.md" "$CLAUDE_RULES/code-comments.md"
|
||||
rm -f "$CLAUDE_HOME/code-comments.md" "$HOME/.agents/AGENTS.md"
|
||||
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repo-specific lints: skill frontmatter, internal path references, and
|
||||
drift between the two installers (bin/link.sh and nix/home.nix).
|
||||
|
||||
Stdlib only, so it runs on any machine without a toolchain. Generic linters
|
||||
(shellcheck, ruff, jq) live in bin/lint.sh instead.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Upstream copies. They follow their own conventions and are replaced wholesale
|
||||
# by a re-vendor, so linting them only produces noise we cannot act on.
|
||||
VENDORED = ("skills/impeccable", "skills/humanizer")
|
||||
|
||||
TOP_DIRS = ("skills", "bin", "hooks", "scripts", "commands", "claude-md", "entry", "systemd", "nix")
|
||||
EXTS = "md|sh|py|ts|mjs|js|json|nix|service|timer|yaml"
|
||||
|
||||
# Frontmatter keys the agents actually read. An unknown key is almost always a
|
||||
# typo, and a typo'd key is ignored silently rather than reported.
|
||||
KNOWN_KEYS = {
|
||||
"name",
|
||||
"description",
|
||||
"user-invocable",
|
||||
"disable-model-invocation",
|
||||
"args",
|
||||
"argument-hint",
|
||||
"allowed-tools",
|
||||
"license",
|
||||
"metadata",
|
||||
"version",
|
||||
}
|
||||
|
||||
problems = []
|
||||
|
||||
|
||||
def report(path, msg):
|
||||
problems.append(f"{path}: {msg}")
|
||||
|
||||
|
||||
def vendored(rel):
|
||||
return any(str(rel).startswith(v) for v in VENDORED)
|
||||
|
||||
|
||||
def repo_files(*globs):
|
||||
for g in globs:
|
||||
for p in sorted(REPO.glob(g)):
|
||||
rel = p.relative_to(REPO)
|
||||
if not vendored(rel):
|
||||
yield p, rel
|
||||
|
||||
|
||||
def read(p):
|
||||
return p.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# --- skill frontmatter -------------------------------------------------------
|
||||
|
||||
|
||||
def frontmatter(text):
|
||||
if not text.startswith("---\n"):
|
||||
return None
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return None
|
||||
return text[4 : end + 1]
|
||||
|
||||
|
||||
def check_skills():
|
||||
names = {}
|
||||
for d in sorted((REPO / "skills").iterdir()):
|
||||
if not d.is_dir() or vendored(d.relative_to(REPO)):
|
||||
continue
|
||||
skill = d / "SKILL.md"
|
||||
if not skill.exists():
|
||||
# *-common dirs are shared fragments pulled in by real skills.
|
||||
if not (d / "COMMON.md").exists():
|
||||
report(f"skills/{d.name}", "has neither SKILL.md nor COMMON.md")
|
||||
continue
|
||||
|
||||
rel = skill.relative_to(REPO)
|
||||
block = frontmatter(read(skill))
|
||||
if block is None:
|
||||
report(rel, "missing YAML frontmatter (--- ... ---)")
|
||||
continue
|
||||
|
||||
keys = re.findall(r"(?m)^([A-Za-z][A-Za-z0-9_-]*):", block)
|
||||
for k in keys:
|
||||
if k not in KNOWN_KEYS:
|
||||
report(rel, f"unknown frontmatter key `{k}`")
|
||||
for dup in {k for k in keys if keys.count(k) > 1}:
|
||||
report(rel, f"duplicate frontmatter key `{dup}`")
|
||||
|
||||
m = re.search(r"(?m)^name:[ \t]*(.*)$", block)
|
||||
if not m:
|
||||
report(rel, "frontmatter has no `name`")
|
||||
else:
|
||||
name = m.group(1).strip().strip("\"'")
|
||||
if name != d.name:
|
||||
report(rel, f"name `{name}` does not match directory `{d.name}`")
|
||||
if name in names:
|
||||
report(rel, f"name `{name}` already used by {names[name]}")
|
||||
names[name] = rel
|
||||
|
||||
m = re.search(r"(?m)^description:[ \t]*(.*)$", block)
|
||||
if not m:
|
||||
report(rel, "frontmatter has no `description`")
|
||||
elif not m.group(1).strip().strip("|>").strip():
|
||||
# Block scalar: the text is on the following indented lines.
|
||||
rest = block[m.end() :]
|
||||
if not re.match(r"(?:\n[ \t]+\S)", rest):
|
||||
report(rel, "`description` is empty")
|
||||
|
||||
|
||||
# --- internal path references ------------------------------------------------
|
||||
|
||||
REF_RE = re.compile(
|
||||
r"<skills-root>/(?P<sr>[A-Za-z0-9_./-]*?\.(?:" + EXTS + r"))(?![A-Za-z0-9_-])"
|
||||
r"|(?<![\w/.~-])(?P<rp>(?:"
|
||||
+ "|".join(TOP_DIRS)
|
||||
+ r")/[A-Za-z0-9_./-]*?\.(?:"
|
||||
+ EXTS
|
||||
+ r"))(?![A-Za-z0-9_-])"
|
||||
)
|
||||
|
||||
LINK_RE = re.compile(r"\]\((?!https?:|mailto:|#)([^)\s#]+)\)")
|
||||
|
||||
|
||||
def check_refs():
|
||||
for p, rel in repo_files("*.md", "*/*.md", "*/*/*.md", "*/*/*/*.md", "*/*.sh", "*/*.py", "*/*/*/*.py", "*/*.nix", "*/*.service"):
|
||||
text = read(p)
|
||||
for m in REF_RE.finditer(text):
|
||||
if m.group("sr"):
|
||||
target = REPO / "skills" / m.group("sr")
|
||||
shown = "<skills-root>/" + m.group("sr")
|
||||
else:
|
||||
shown = m.group("rp")
|
||||
# Same string can be repo-relative or relative to the skill dir:
|
||||
# a SKILL.md naming a data file next to it means the latter.
|
||||
bases = [REPO, p.parent]
|
||||
if rel.parts[0] == "skills" and len(rel.parts) > 1:
|
||||
bases.append(REPO / "skills" / rel.parts[1])
|
||||
target = next((b / shown for b in bases if (b / shown).exists()), REPO / shown)
|
||||
if not target.exists():
|
||||
report(rel, f"references missing path `{shown}`")
|
||||
|
||||
if p.suffix == ".md":
|
||||
for m in LINK_RE.finditer(text):
|
||||
link = m.group(1)
|
||||
if any(c in link for c in "<>$*~") or link.startswith("/"):
|
||||
continue
|
||||
if not (p.parent / link).exists():
|
||||
report(rel, f"broken relative link `{link}`")
|
||||
|
||||
|
||||
# --- installer drift ---------------------------------------------------------
|
||||
|
||||
# systemd/: link.sh links the unit files into ~/.config/systemd/user, home.nix
|
||||
# declares equivalent units natively. Same result, different mechanism.
|
||||
# hooks/README.md: docs, not a hook; harmless whether or not it is installed.
|
||||
DRIFT_ALLOWED = ("systemd/", "hooks/README.md")
|
||||
|
||||
|
||||
def expand(spec):
|
||||
"""Repo-relative glob (dirs walked to their files) -> set of files."""
|
||||
out = set()
|
||||
for p in REPO.glob(spec.strip('";/ \t')):
|
||||
if p.is_dir():
|
||||
out |= {q.relative_to(REPO) for q in p.rglob("*") if q.is_file()}
|
||||
elif p.is_file():
|
||||
out.add(p.relative_to(REPO))
|
||||
# Vendored trees move wholesale; __pycache__ is not tracked.
|
||||
return {f for f in out if not vendored(f) and "__pycache__" not in f.parts}
|
||||
|
||||
|
||||
def installed_by(path, var_re):
|
||||
files = set()
|
||||
for m in var_re.finditer(read(path)):
|
||||
spec = m.group(1)
|
||||
spec = re.sub(r"\$\{?[A-Za-z_][A-Za-z0-9_.:${}-]*\}?", "*", spec)
|
||||
files |= expand(spec)
|
||||
return files
|
||||
|
||||
|
||||
def check_drift():
|
||||
sh = installed_by(REPO / "bin/link.sh", re.compile(r'\$REPO"?/([^"\s]+)'))
|
||||
nix = installed_by(REPO / "nix/home.nix", re.compile(r'\$\{agent-skills\}/([^"\s]+)'))
|
||||
|
||||
def ignored(f):
|
||||
return str(f).startswith(DRIFT_ALLOWED)
|
||||
|
||||
for f in sorted(sh - nix):
|
||||
if not ignored(f):
|
||||
report("nix/home.nix", f"bin/link.sh installs `{f}`, this does not")
|
||||
for f in sorted(nix - sh):
|
||||
if not ignored(f):
|
||||
report("bin/link.sh", f"nix/home.nix installs `{f}`, this does not")
|
||||
|
||||
|
||||
# --- entry-file imports resolve ----------------------------------------------
|
||||
|
||||
|
||||
def check_entry_imports():
|
||||
"""`@~/.claude/x.md` in an entry file only resolves if both installers put
|
||||
x.md there. A missing one makes every session start with a failed import."""
|
||||
nix_dest = set(re.findall(r'"\.claude/([^"/]+\.md)"\.source', read(REPO / "nix/home.nix")))
|
||||
|
||||
link_sh = read(REPO / "bin/link.sh")
|
||||
sh_dest = set(re.findall(r'"\$CLAUDE_HOME/([^"]+)"', link_sh))
|
||||
if '"$CLAUDE_HOME/$(basename "$f")"' in link_sh:
|
||||
sh_dest.discard('$(basename "$f")')
|
||||
skipped = set(re.findall(r'basename "\$f"\)" = "([^"]+)"', link_sh))
|
||||
sh_dest |= {p.name for _, p in repo_files("claude-md/*.md")} - skipped
|
||||
|
||||
for p, rel in repo_files("entry/*.md"):
|
||||
for name in re.findall(r"@~/\.claude/([A-Za-z0-9_.-]+\.md)", read(p)):
|
||||
if name not in sh_dest:
|
||||
report("bin/link.sh", f"{rel} imports ~/.claude/{name}, which it never creates")
|
||||
if name not in nix_dest:
|
||||
report("nix/home.nix", f"{rel} imports ~/.claude/{name}, which it never creates")
|
||||
|
||||
|
||||
# --- unit ExecStart paths ----------------------------------------------------
|
||||
|
||||
UNIT_PATH_RE = re.compile(r"(?:%h/tea/(?:yolo/)?agent-skills|\$\{repo\})/([A-Za-z0-9_./-]+)")
|
||||
|
||||
|
||||
def check_unit_paths():
|
||||
for p, rel in repo_files("systemd/*.service", "nix/home.nix"):
|
||||
for m in UNIT_PATH_RE.finditer(read(p)):
|
||||
if not (REPO / m.group(1)).exists():
|
||||
report(rel, f"unit points at missing `{m.group(1)}`")
|
||||
|
||||
|
||||
def main():
|
||||
for check in (check_skills, check_refs, check_drift, check_entry_imports, check_unit_paths):
|
||||
check()
|
||||
for line in problems:
|
||||
print(line)
|
||||
if problems:
|
||||
print(f"\n{len(problems)} problem(s).")
|
||||
return 1
|
||||
print("lint-repo: ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs every check CI runs. Missing tools are skipped with a note, so this
|
||||
# works on a bare machine; LINT_STRICT=1 (what CI sets) turns a skip into a
|
||||
# failure, so a tool silently missing from the runner cannot pass as green.
|
||||
set -uo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO" || exit 1
|
||||
|
||||
STRICT="${LINT_STRICT:-0}"
|
||||
fail=0
|
||||
|
||||
# Vendored skills are upstream copies: they follow their own conventions and a
|
||||
# re-vendor replaces them wholesale, so linting them only makes noise.
|
||||
own() { git ls-files "$@" | grep -v -e '^skills/impeccable/' -e '^skills/humanizer/'; }
|
||||
|
||||
run() { # run <name> <cmd...>
|
||||
local name="$1"
|
||||
shift
|
||||
printf '\n== %s\n' "$name"
|
||||
"$@" || fail=1
|
||||
}
|
||||
|
||||
skip() { # skip <name> <tool>
|
||||
printf '\n== %s\n' "$1"
|
||||
if [ "$STRICT" = 1 ]; then
|
||||
echo "$2 is not installed"
|
||||
fail=1
|
||||
else
|
||||
echo "skipped ($2 not installed)"
|
||||
fi
|
||||
}
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
run "repo lints" python3 bin/lint-repo.py
|
||||
|
||||
if have shellcheck; then
|
||||
# shellcheck disable=SC2046
|
||||
run "shellcheck" shellcheck $(own '*.sh')
|
||||
else
|
||||
skip "shellcheck" shellcheck
|
||||
fi
|
||||
|
||||
if have ruff; then
|
||||
run "ruff" ruff check .
|
||||
else
|
||||
skip "ruff" ruff
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2046
|
||||
run "python syntax" python3 -m compileall -q $(own '*.py')
|
||||
|
||||
if have jq; then
|
||||
printf '\n== json\n'
|
||||
bad=0
|
||||
while read -r f; do
|
||||
jq -e . "$f" >/dev/null 2>&1 || { echo "invalid JSON: $f"; bad=1; }
|
||||
done < <(own '*.json')
|
||||
[ "$bad" = 0 ] && echo "ok" || fail=1
|
||||
else
|
||||
skip "json" jq
|
||||
fi
|
||||
|
||||
if have node; then
|
||||
printf '\n== js syntax\n'
|
||||
bad=0
|
||||
while read -r f; do
|
||||
node --check "$f" || bad=1
|
||||
done < <(own '*.js' '*.mjs')
|
||||
[ "$bad" = 0 ] && echo "ok" || fail=1
|
||||
else
|
||||
skip "js syntax" node
|
||||
fi
|
||||
|
||||
# The flake is what NixOS boxes install from; nothing else evaluates home.nix.
|
||||
# CI runs it as its own job (installing nix costs more than the rest combined),
|
||||
# so the lint job opts out rather than reporting a false skip.
|
||||
if [ "${LINT_NO_NIX:-0}" = 1 ]; then
|
||||
printf '\n== nix flake check\nskipped (LINT_NO_NIX=1)\n'
|
||||
elif have nix; then
|
||||
run "nix flake check" nix flake check --no-write-lock-file
|
||||
else
|
||||
skip "nix flake check" nix
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
[ "$fail" = 0 ] && echo "all checks passed" || echo "FAILED"
|
||||
exit "$fail"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Vendored skills are upstream copies, replaced wholesale on a re-vendor.
|
||||
extend-exclude = ["skills/impeccable", "skills/humanizer"]
|
||||
|
||||
line-length = 100
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP", "C4"]
|
||||
# These are single-file operator scripts, not a library: long prose strings in
|
||||
# --help text and report output are the norm.
|
||||
ignore = ["E501"]
|
||||
@@ -19,6 +19,7 @@ Commands:
|
||||
|
||||
Every write takes --dry-run, which prints the exact request and sends nothing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
@@ -27,6 +28,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def request(method, path, body=None, dry=False):
|
||||
api = os.environ.get("HOURLOG_API")
|
||||
if not api:
|
||||
@@ -85,13 +87,13 @@ def main():
|
||||
c.add_argument("--hours", required=True, help="decimal hours, e.g. 4 or 7.5")
|
||||
c.add_argument("--dry-run", action="store_true")
|
||||
|
||||
l = sub.add_parser("log")
|
||||
g = l.add_mutually_exclusive_group(required=True)
|
||||
log_p = sub.add_parser("log")
|
||||
g = log_p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--project", help="project id")
|
||||
g.add_argument("--category", help="investment category id")
|
||||
l.add_argument("--dates", required=True, help="comma-separated YYYY-MM-DD")
|
||||
l.add_argument("--hours", required=True, help="decimal hours per day")
|
||||
l.add_argument("--dry-run", action="store_true")
|
||||
log_p.add_argument("--dates", required=True, help="comma-separated YYYY-MM-DD")
|
||||
log_p.add_argument("--hours", required=True, help="decimal hours per day")
|
||||
log_p.add_argument("--dry-run", action="store_true")
|
||||
|
||||
r = sub.add_parser("raw")
|
||||
r.add_argument("method")
|
||||
|
||||
Reference in New Issue
Block a user