8 Commits

Author SHA1 Message Date
naudachu 81119a3bd9 merge: bring the README layout and the branch claim back to the code 2026-08-10 20:07:23 +05:00
naudachu f97ac952f7 merge: separate the API-only flags from the ones needing a checkout 2026-08-10 20:07:23 +05:00
naudachu 493a787940 merge: implement the wiki field in the issue domain 2026-08-10 20:07:08 +05:00
naudachu 7ab967bfaa merge: guard the CLI command, not the word 2026-08-10 20:06:52 +05:00
naudachu edb2f5a627 docs: bring the README layout and the branch claim back to the code
README described a three-skill plugin that ships six. skills/page and
skills/wiki were absent from What it ships and from the project tree, so
/tea:page and /tea:wiki could not be discovered from the front page at
all; labels.py, close.py, evict.py, issue_evict.py, pin.py and the
agents-sync hook were missing from the tree too. Layout now matches
AGENTS.md, and says which of the two is authoritative.

The sync skill promised that push writes the computed branch back into
the issue file. It cannot: a successful push deletes the file, which
push.py:260-271 and its docstring already said. The paragraph now says
what happens instead — the ref goes up, and the branch comes back on the
next pull, from the tracker. The three claims around it were correct and
are kept.

Closes #27
Closes #31

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:03:27 +05:00
naudachu 62027db76c docs: separate the API-only flags from the ones needing a checkout
Two flags in the reference said one thing and did another.

`-o` was listed as a global output-format flag and the Tips section
recommended `-o json` without qualification. On `tea api` it is a file
name: `-o json` writes the body to a file called json and leaves stdout
empty, so the next parse fails with a JSONDecodeError that looks like a
server refusal. Scoped to entity commands, and api's meaning named in
all three places that mention it.

`--repo owner/repo` was documented as a general substitute for standing
in a checkout. For `pulls create`, `pulls checkout` and `pulls clean` it
is not: the slug is rejected with 'local repository required', advice
that reads like the flag was missing. Verified from outside any repo —
those three refuse a slug and accept a path, while pulls list,
milestones, releases and times take the slug from anywhere. All three
working forms are written down, including the git-worktree one (point
--repo at the main checkout) and the api fallback.

Closes #35
Closes #28

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:00:13 +05:00
naudachu 9479babfe9 feat: implement the wiki field in the issue domain
format.md listed `wiki:` among the domain fields, between depends and
origin, and issue.py had never heard of it. The field fell into extra and
rendered with the foreign keys — sorted in after the sync fields, which
the same document forbids one line below the table. Written without
brackets it parsed as a single string, and nothing but a text editor
could set it.

Implemented rather than de-documented: page_ls.py --titles already
prints these titles, so the field was designed and only unwired.
DOMAIN_KEYS and LIST_KEYS learn it, Issue carries it, and issue_new.py
gets a repeatable --wiki flag.

Titles only, as the format says: no path, no sub_url, no lookup. The
tracker has no field for it, so it is never sent and a pull does not
bring it back — format.md now says so.

Closes #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:55:10 +05:00
naudachu e330a11e8f fix: guard the tea command, not the word tea
The guard tested whether the command string contained `tea` between
whitespace. In a repository whose subject is the CLI, that blocked prose:
an issue title, a commit message quoting a raw call, `grep -rn " tea "`
and `echo tea`. The block message told the operator to add --login to
git commit, which cannot be done — the only way past was to reword the
sentence.

The command is now tokenized (heredoc bodies dropped, line continuations
folded, backticks and newlines treated as boundaries) and only words in
command position count: the first word, and the first after a shell
operator, past VAR=value assignments and prefix words. Quoting is what
saves the prose — a title is one token, and a token is never a command.

Every invocation in the line is checked and rewritten, not just the
first: a half-rewritten line left the second call with an unset variable
and no login. The whitelist is now per-invocation too, so quoting
"tea logins list" beside a real call no longer launders it.

An untokenizable line (unbalanced quotes) falls back to the old
substring test, which over-matches and therefore blocks.

Closes #29

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:49:38 +05:00
12 changed files with 649 additions and 55 deletions
+44 -11
View File
@@ -8,30 +8,36 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce
|---|---|
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment |
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
| `/tea:page` skill | A discussion's artifacts as a named, ordered tree of pages — import, title, index. Entirely offline |
| `/tea:wiki` skill | Moves page trees between a local space and a Gitea wiki — fetch a subtree, publish one |
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
## The layering
An issue is a unit of work first and a Gitea row second. Those are two layers,
and knowledge flows one way:
An issue is a unit of work first and a Gitea row second. A page tree is a
discussion's artifacts first and a wiki second. Each is two layers, and
knowledge flows one way:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
skills/page DOMAIN what a page tree is: title <-> path, order, the index
▲ offline — no tracker, no network, stdlib only
│ imports
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
skills/wiki BRIDGE md <-> Gitea wiki JSON; transport is sync's _gitea.py
│ calls
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
```
Delete `skills/sync` and the domain layer keeps working — issues that live only
on your machine are first-class, not drafts waiting to be uploaded. That is the
point of the split: you can plan, write, validate, and track work without a
tracker, and publish only what you choose to.
Delete `skills/sync` and the issue domain keeps working; delete `skills/wiki`
and page trees keep working. Work that lives only on your machine is
first-class, not a draft waiting to be uploaded. That is the point of the
split: you can plan, write, validate, and organize without a tracker, and
publish only what you choose to.
## Prerequisites
@@ -120,11 +126,15 @@ session — the pinned login is enforced on every call it makes.
agents/
tea-runner.md subagent (Haiku) that executes the scripts
hooks/
hooks.json registers the PreToolUse hook
hooks.json registers the PreToolUse hooks
tea-guard.sh the guard (Python 3, no deps)
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
skills/
auth/SKILL.md /tea:auth skill
issue/ /tea:issue — the domain layer, offline
auth/ /tea:auth — the identity layer
SKILL.md
scripts/pin.py where the login pin is and how it is found —
imported by _gitea.py AND by tea-guard.sh
issue/ /tea:issue — the issue domain, offline
SKILL.md
references/format.md canonical issue format (identity, types, templates)
scripts/ Python 3, stdlib only, no network:
@@ -135,6 +145,7 @@ skills/
issue_check.py validate against the format
issue_ac.py list the body's checkboxes; tick one
issue_tree.py draw the dependency graph
issue_evict.py drop closed issues the tracker also has
issue_index.py rebuild tmp/issues/INDEX.md
sync/ /tea:sync — the bridge to Gitea
SKILL.md
@@ -145,11 +156,33 @@ skills/
push.py tmp/issues/ -> Gitea, then drops the local file
remote.py discovery listing to stdout
comment.py post or edit a comment
close.py the state field, both ways
evict.py refresh state: from Gitea, then evict
labels.py put the canonical label set into a repository
page/ /tea:page — the page-tree domain, offline
SKILL.md
references/pages.md canonical page-tree format
scripts/ Python 3, stdlib only, no network:
page.py domain module: title <-> path, ordering,
the manifest, importing, the index
page_import.py copy a directory of markdown into a space
page_index.py write the table-of-contents page
page_ls.py the tree, the titles, one state tag per page
wiki/ /tea:wiki — the bridge to a Gitea wiki
SKILL.md
scripts/
wikimap.py md <-> Gitea wiki JSON, pure, no I/O
wiki_ls.py what the wiki holds
wiki_pull.py wiki -> tmp/wiki/<space>/
wiki_push.py tmp/wiki/<space>/ -> wiki (additive)
use/ /tea:use — tea CLI reference (non-issue entities)
SKILL.md
references/tea/ command docs
```
`AGENTS.md` carries the same layout with the reasoning behind it; if the two
ever disagree, `AGENTS.md` is the one being worked from.
## Local issue store
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
+177 -29
View File
@@ -27,6 +27,27 @@ Rules:
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
"A `tea` command" means the shell would RUN `tea`, not that the string contains
the word. The guard used to ask the second question — a substring search over
the whole command line — and in a repository whose subject *is* the CLI that is
a different question with the same answer far too often: an issue title, a
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
a message telling the operator to add `--login` to `git commit`. Worse, the
advice was unfollowable: the only way past the guard was to reword the prose.
So the command is tokenized (heredoc bodies dropped, line continuations
folded, backticks and newlines treated as boundaries) and only words in
*command position* count — the first word, and the first word after `;`, `&&`,
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
`-m` message is one token, and one token is never a command. Compound commands
stay guarded segment by segment, substitutions included, and every `tea` in the
line is checked — not just the first.
If the line cannot be tokenized at all (unbalanced quotes), the old substring
test decides. That direction fails closed: it over-matches, and over-matching
blocks.
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
rewrite; exit 2 + stderr to block.
"""
@@ -46,6 +67,131 @@ except Exception:
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
# Operators after which the next word is a command again.
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
# Words that stand in front of a command without being one.
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
"if", "then", "else", "elif", "while", "until", "do", "!"}
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
# A login flag and its value, in the ORIGINAL text — this is what gets
# rewritten, so it works on the raw string rather than on tokens.
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
'(the guard substitutes the operator-pinned login). '
'Run /tea:auth if no login is pinned.')
def named_login(raw):
return ('do not name the login yourself (got `%s`). Write exactly '
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
'the operator pinned via /tea:auth. This prevents acting under '
'the wrong identity.' % raw)
def unquote(value):
for q in ('"', "'"):
if len(value) >= 2 and value[0] == q and value[-1] == q:
return value[1:-1]
return value
def strip_heredocs(cmd):
"""Drop heredoc bodies. They are data the shell feeds to a command, not
commands — and a commit message quoting a raw `tea api` call is exactly the
thing that used to be unwritable."""
lines, kept, i = cmd.split("\n"), [], 0
while i < len(lines):
line = lines[i]
kept.append(line)
i += 1
for m in HEREDOC.finditer(line):
delim, dash = m.group(2), m.group(0).startswith("<<-")
while i < len(lines):
probe = lines[i].strip() if dash else lines[i].rstrip()
i += 1
if probe == delim:
break
return "\n".join(kept)
def shell_words(cmd):
"""Tokens, with operators as tokens of their own and quotes honored.
Backticks and newlines become separators before tokenizing: shlex knows
neither, and both start a command. Inside quotes that substitution is
harmless — the token still spans the quotes, and a token is never a
command."""
text = strip_heredocs(cmd)
text = re.sub(r"\\\n", " ", text)
text = text.replace("`", " ; ").replace("\n", " ; ")
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
lex.whitespace_split = True
return list(lex)
def tea_invocations(words):
"""The argument list of every `tea` the shell would actually run."""
found, current, expect, skip = [], None, True, False
for w in words:
if skip:
skip = False
continue
if REDIRECT.match(w):
skip = True # the target of a redirection is not a command
continue
if w in SEPARATORS:
current, expect = None, True
continue
if expect:
if ASSIGNMENT.match(w) or w in TRANSPARENT:
continue
expect = False
if w.rsplit("/", 1)[-1] == "tea":
current = []
found.append(current)
continue
if current is not None:
current.append(w)
return found
def is_meta(args):
"""Login enumeration and `--version`/`--help`: no identity is used, and
/tea:auth needs `tea logins list` while no pin exists yet."""
if not args:
return False
if args[0] in ("--version", "-v", "--help", "-h", "help"):
return True
return args[0] in ("logins", "login") and len(args) > 1 \
and args[1] in ("list", "ls")
def login_value(args):
"""The login as written, or None if the flag is absent."""
for i, a in enumerate(args):
if a in ("--login", "-l"):
return args[i + 1] if i + 1 < len(args) else ""
if a.startswith("--login=") or a.startswith("-l="):
return a.split("=", 1)[1]
return None
def substitute(cmd, login):
"""Every placeholder login in the line, replaced by the pin. Every one:
a command may run `tea` twice, and half a rewrite leaves the second call
with an unset variable and no login at all."""
def repl(m):
if unquote(m.group(3)) in PLACEHOLDERS:
return m.group(1) + m.group(2) + shlex.quote(login)
return m.group(0)
return LOGIN_FLAG.sub(repl, cmd)
def block(msg):
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
@@ -81,34 +227,37 @@ def main():
tool_input = payload.get("tool_input") or {}
cmd = tool_input.get("command") or ""
# Not a `tea` invocation → not our concern.
if not re.search(r'(^|[;&|(]|\s)tea(\s|$)', cmd):
allow_passthrough()
try:
runs = tea_invocations(shell_words(cmd))
except ValueError:
# Unbalanced quotes: what the shell would run is not knowable here.
# Fall back to the substring test — it over-matches, and over-matching
# blocks rather than lets an unpinned call through.
runs = None
# Whitelist: login enumeration + meta. No identity is used; /tea:auth
# needs `tea logins list` while no pin exists yet.
if re.search(r'tea\s+(logins\s+(list|ls)|--version|-v|--help|help)(\s|$)', cmd):
allow_passthrough()
# Locate --login / -l and its value (logins never contain spaces).
m = re.search(r'(--login|(?<![\w-])-l)(\s+|=)(\S+)', cmd)
if not m:
block('every `tea` command must include --login "$GITEA_LOGIN" '
'(the guard substitutes the operator-pinned login). '
'Run /tea:auth if no login is pinned.')
raw_val = m.group(3)
inner = raw_val
for q in ('"', "'"):
if len(inner) >= 2 and inner[0] == q and inner[-1] == q:
inner = inner[1:-1]
break
if inner not in PLACEHOLDERS:
block('do not name the login yourself (got `%s`). Write exactly '
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
'the operator pinned via /tea:auth. This prevents acting under '
'the wrong identity.' % raw_val)
if runs is None:
if not LOOKS_LIKE_TEA.search(cmd):
allow_passthrough()
m = LOGIN_FLAG.search(cmd)
if not m:
block(NO_LOGIN)
if unquote(m.group(3)) not in PLACEHOLDERS:
block(named_login(m.group(3)))
else:
# The word appears but nothing runs it → not our concern. This is the
# branch that lets prose about the CLI be written at all.
if not runs:
allow_passthrough()
for args in runs:
if is_meta(args):
continue
raw = login_value(args)
if raw is None:
block(NO_LOGIN)
if unquote(raw) not in PLACEHOLDERS:
block(named_login(raw))
if all(is_meta(args) for args in runs):
allow_passthrough()
if pin is None:
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
@@ -123,8 +272,7 @@ def main():
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
'the file at call time, so it takes effect with no restart.')
new_cmd = cmd[:m.start(3)] + shlex.quote(login) + cmd[m.end(3):]
rewrite(tool_input, new_cmd,
rewrite(tool_input, substitute(cmd, login),
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
+1 -1
View File
@@ -64,7 +64,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
| `assignees` | domain | logins; may be empty |
| `milestone` | domain | title, or `none` |
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them. Set it with `issue_new.py --wiki "<title>"` (repeatable) or by editing the line. The tracker has no field for it, so it is never sent — and a pull, which merges nothing but checkbox state, does not bring it back |
| `origin` | domain | `local`, or the name of a tracker this also lives in |
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
+12 -5
View File
@@ -25,6 +25,7 @@ domain has. The file name is the id:
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
wiki: [Simple Chains/Ideas/Chain core]
origin: gitea
gitea: owner/repo#42
synced: 2026-08-07T18:40:00Z
@@ -113,8 +114,9 @@ ISSUE_ROOT = store_root()
# Domain-owned metadata, in render order. Foreign keys render after these,
# sorted, so the sync layer can add fields without touching this list.
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"]
LIST_KEYS = {"labels", "assignees", "depends"}
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
"wiki", "origin"]
LIST_KEYS = {"labels", "assignees", "depends", "wiki"}
STATES = ("open", "closed")
# `origin` is "does this issue exist anywhere but here" — a fact about the
@@ -247,8 +249,8 @@ class Issue(object):
"""One unit of work. `extra` holds metadata this layer does not own."""
def __init__(self, id="", title="", body="", state="open", labels=None,
assignees=None, milestone="", depends=None, origin=LOCAL,
extra=None):
assignees=None, milestone="", depends=None, wiki=None,
origin=LOCAL, extra=None):
self.id = id
self.title = title
self.body = body
@@ -257,6 +259,10 @@ class Issue(object):
self.assignees = list(assignees or [])
self.milestone = milestone or ""
self.depends = list(depends or [])
# Page TITLES this work is written up in — names for documents, which
# is why they are domain-owned. What a title resolves to is /tea:page's
# business, and this layer never asks: no path, no URL, no lookup.
self.wiki = list(wiki or [])
self.origin = origin or LOCAL
self.extra = dict(extra or {})
@@ -302,7 +308,7 @@ class Issue(object):
state=meta.get("state") or "open",
labels=lst("labels"), assignees=lst("assignees"),
milestone="" if ms == "none" else ms,
depends=lst("depends"),
depends=lst("depends"), wiki=lst("wiki"),
origin=meta.get("origin") or LOCAL, extra=extra)
def to_text(self):
@@ -314,6 +320,7 @@ class Issue(object):
"assignees": self.assignees,
"milestone": self.milestone or "none",
"depends": self.depends,
"wiki": self.wiki,
"origin": self.origin,
})
body = self.body.strip() or "(no body)"
+3 -1
View File
@@ -155,6 +155,8 @@ def main():
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
ap.add_argument("--depends", action="append", default=[],
help="id this issue depends on; repeat")
ap.add_argument("--wiki", action="append", default=[],
help="page title this work is written up in; repeat")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
@@ -180,7 +182,7 @@ def main():
id=id, title=args.title,
body=with_depends(TEMPLATES[args.type], args.depends),
labels=labels, assignees=args.assignee, milestone=args.milestone,
depends=args.depends)
depends=args.depends, wiki=args.wiki)
# The first issue in a fresh checkout has to create the store, but it says
# so — and it says where, because the path is absolute.
+5 -2
View File
@@ -355,8 +355,11 @@ A milestone must already exist in the repo — push attaches, it does not create
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
and writes it back into the issue file; a value already there is never
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
and sends it up as `ref`; a value already there is never
overwritten, neither on create nor on `--update`. Nothing is written back to
the issue file — there is no file left to write to, because a successful push
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
the tracker. On a detached HEAD or outside
a git repo no `ref` is sent and a warning names the issues that went up without
one. Reading the branch is the only thing these scripts ask git for — they
never check out, create, or write anything.
+36 -1
View File
@@ -58,6 +58,37 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
per-project by the operator (see `/tea:auth`) and injected by the guard.
Config lives in `$XDG_CONFIG_HOME/tea`.
### `--repo` takes a slug — except where a checkout is required
A few commands touch local git, not just the API, and for those `--repo`
**must be a path to a checkout**; a slug is rejected:
```
Error: local repository required: execute from a repo dir, or specify a path with --repo
```
The message reads like the flag is missing even when it was passed. Confirmed
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
`labels`, `issues` — takes the slug from any directory.
Three working forms for `pulls create`:
```bash
# 1. cwd inside the checkout, no --repo at all
tea pulls create --login "$GITEA_LOGIN" --head feat/x --base main \
--title "…" --description "…"
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
# point it at the main checkout)
tea pulls create --login "$GITEA_LOGIN" --repo /path/to/checkout \
--head feat/x --base main --title "…" --description "…"
# 3. no checkout in reach — POST it, where owner/repo is a slug again
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/pull/x.json \
repos/{owner}/{repo}/pulls
```
## Index
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
@@ -125,7 +156,11 @@ are still fine via entity commands. Always the placeholder, never a login name.
## Tips
- Pass `-o json` for structured output when parsing programmatically.
- Pass `-o json` for structured output when parsing programmatically — on
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
writes the response body to a file called `json` and leaves stdout empty.
The response is already JSON, so there is nothing to format; use `-` for
stdout, or leave the flag off.
- Use `--fields, -f` to narrow columns.
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
- If a `tea` command is blocked by `tea-guard`: either you forgot
+9 -2
View File
@@ -19,9 +19,16 @@ Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,aut
Subcommands:
- `list, ls` (`--state`)
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing.
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash.
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. Needs a checkout, same as `create`: `--repo` is a path here, not a slug.
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. Needs a checkout, same as `create`.
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
**Needs a local checkout.** `--repo owner/repo` is *not* accepted here — the
slug fails with `local repository required: execute from a repo dir, or
specify a path with --repo`, whose advice reads like the flag was missing.
Run it with cwd inside the checkout and no `--repo`, or pass `--repo
/path/to/checkout`. From a git worktree, point `--repo` at the main
checkout. With no checkout in reach, `POST repos/{owner}/{repo}/pulls`
through `tea api`, which takes the slug.
- `close <idx>...`, `reopen, open <idx>...`
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
- `review <idx>` — interactive review.
+1 -1
View File
@@ -26,5 +26,5 @@ Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
- `--header/-H key:value` (repeatable)
- `--include/-i` — write status + response headers to stderr.
- `--output/-o <file>` — write response body to file (`-` = stdout).
- `--output/-o <file>` — write response body to file (`-` = stdout). **Not the entity commands' format flag**: `-o json` here creates a file named `json` and prints nothing. The body is already JSON.
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
+2 -2
View File
@@ -14,9 +14,9 @@ Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Up
| Flag | Purpose |
|---|---|
| `--login, -l <name>` | use a specific login from the config |
| `--repo, -r <owner/repo>` | override repository context (local path or slug) |
| `--repo, -r <owner/repo>` | override repository context (local path or slug). **A slug only works where the command is pure API.** `pulls create`, `pulls checkout` and `pulls clean` need a real checkout and read this flag as a path — see [SKILL.md](../../SKILL.md) |
| `--remote, -R <name>` | discover login from this git remote |
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json` |
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json`. **Entity commands only** — on `tea api` the same flag is a FILE NAME, see [HELPERS](./helpers.md) |
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
| `--fields, -f <list>` | which columns to print |
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
What the guard guards: `tea` the command, not `tea` the word.
python3 -m unittest discover -s tests -v
The bug these tests hold down: the guard asked whether the string contained
`tea` surrounded by whitespace, so in a repository *about* the CLI it blocked
prose. An issue title, a commit message quoting a raw call, `grep -rn " tea "`
and `echo tea` were all refused, with a message telling the operator to add
`--login` to `git commit`. The advice could not be followed — the only way
past was to reword the sentence.
Two lines are held at once here, and neither may move without the other: the
four false positives pass, and every shape that really runs the CLI — after
`&&`, after a pipe, in a subshell, in a substitution, twice in one line — is
still blocked or still rewritten. A test that only proved the first would be
satisfied by deleting the guard.
No network and no `tea` binary: the hook is pure decision-making, so the
fixture is a directory with a pin in it and a JSON payload on stdin.
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GUARD = os.path.join(REPO, "hooks", "tea-guard.sh")
sys.path.insert(0, os.path.join(REPO, "skills", "auth", "scripts"))
import pin # noqa: E402
LOGIN = "fixture/user"
ALLOW, BLOCK, REWRITE = "allow", "block", "rewrite"
class GuardCase(unittest.TestCase):
"""One temp project with one pinned login; the hook run as the harness
runs it."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="tea-guard-")
self.root = os.path.realpath(self._tmp.name)
self.addCleanup(self._tmp.cleanup)
path = pin.settings_path(self.root)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(json.dumps({"env": {pin.ENV_KEY: LOGIN}}))
def run_guard(self, cmd):
env = dict(os.environ)
env.pop("PYTHONPATH", None)
env[pin.PROJECT_DIR_ENV] = self.root
p = subprocess.run([sys.executable, GUARD],
input=json.dumps({"tool_input": {"command": cmd},
"cwd": self.root}),
cwd=self.root, env=env,
capture_output=True, text=True)
return p
def verdict(self, cmd):
p = self.run_guard(cmd)
if p.returncode == 2:
return BLOCK, p.stderr
self.assertEqual(p.returncode, 0, p.stderr)
if not p.stdout.strip():
return ALLOW, ""
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
return REWRITE, got
def assertVerdict(self, cmd, expected):
kind, detail = self.verdict(cmd)
self.assertEqual(kind, expected,
"%r%s (%s)" % (cmd, kind, detail.strip()))
return detail
# --------------------------------------------------------------------------
# the four false positives, verbatim from the report
# --------------------------------------------------------------------------
class TestProseAboutTheCliRuns(GuardCase):
def test_an_issue_title_may_name_the_command(self):
self.assertVerdict(
'python3 skills/issue/scripts/issue_new.py --type bug '
'--title "Warn that tea pulls create needs the repo checkout" '
'--label comp/use --severity low', ALLOW)
def test_a_commit_message_may_quote_a_raw_call(self):
self.assertVerdict(
"git add -A && git commit -F- <<'EOF'\n"
"feat: close issues through a script\n"
"\n"
"Единственным способом сменить state был сырой вызов\n"
"tea api -X PATCH ... repos/OWNER/REPO/issues/N\n"
"EOF", ALLOW)
def test_a_one_line_commit_message_may_too(self):
self.assertVerdict('git commit -m "route it through tea api"', ALLOW)
def test_searching_the_repository_for_the_word(self):
for cmd in ('grep -rn " tea " docs/',
'grep -rn "tea api" skills/',
'echo tea'):
self.assertVerdict(cmd, ALLOW)
def test_the_word_as_a_bare_argument_is_still_an_argument(self):
"""`echo tea` was the smallest case in the report; these are the same
shape with the word in other argument positions."""
for cmd in ('ls tea', 'cat notes/tea', 'python3 x.py tea api'):
self.assertVerdict(cmd, ALLOW)
# --------------------------------------------------------------------------
# and the real thing is still guarded
# --------------------------------------------------------------------------
class TestRealInvocationsStayGuarded(GuardCase):
def test_a_bare_call_without_a_login_is_blocked(self):
detail = self.assertVerdict("tea issues list", BLOCK)
self.assertIn("--login", detail)
def test_the_placeholder_is_rewritten_to_the_pin(self):
got = self.assertVerdict(
'tea issues list --login "$GITEA_LOGIN" --state open', REWRITE)
self.assertIn(LOGIN, got)
self.assertNotIn("GITEA_LOGIN", got)
def test_a_login_named_by_hand_is_blocked(self):
detail = self.assertVerdict("tea issues list --login somebody", BLOCK)
self.assertIn("do not name the login", detail)
def test_another_variable_is_not_the_placeholder(self):
self.assertVerdict('tea issues list --login "$OTHER"', BLOCK)
def test_compound_commands_are_read_segment_by_segment(self):
for cmd in ('cd /tmp && tea issues list',
'echo x | tea api -X GET repos/x/y',
'( tea issues list )',
'cd /tmp; tea issues list',
'FOO=1 tea issues list',
'sudo tea issues list',
'xargs tea issues list'):
self.assertVerdict(cmd, BLOCK)
def test_substitutions_are_read_too(self):
for cmd in ('echo $(tea whoami)',
'x=$(tea whoami)',
'echo `tea whoami`'):
self.assertVerdict(cmd, BLOCK)
def test_a_guarded_call_beside_prose_that_mentions_the_word(self):
"""The two halves of the bug in one line: the guard must ignore the
argument and still catch the call."""
self.assertVerdict(
'git commit -m "route it through tea api" && tea issues list',
BLOCK)
def test_an_absolute_path_to_the_binary_is_the_binary(self):
self.assertVerdict("/usr/local/bin/tea issues list", BLOCK)
def test_every_call_in_the_line_is_rewritten(self):
"""A half-rewritten line leaves the second call with an unset variable
and therefore no login at all."""
got = self.assertVerdict(
'tea issues list --login "$GITEA_LOGIN" && '
'tea pulls list --login "$GITEA_LOGIN"', REWRITE)
self.assertEqual(got.count(LOGIN), 2)
self.assertNotIn("GITEA_LOGIN", got)
def test_a_second_unguarded_call_is_not_covered_by_the_first(self):
self.assertVerdict(
'tea issues list --login "$GITEA_LOGIN" && tea pulls list', BLOCK)
def test_prose_naming_the_whitelisted_form_does_not_launder_a_call(self):
"""`tea logins list` is allowed because it uses no identity. Quoting
that phrase must not turn the call beside it into a whitelisted one."""
self.assertVerdict(
'echo "run tea logins list first" && tea issues list', BLOCK)
class TestTheWhitelistStillApplies(GuardCase):
def test_login_enumeration_needs_no_pin(self):
for cmd in ("tea logins list", "tea logins ls",
"tea --version", "tea --help"):
self.assertVerdict(cmd, ALLOW)
def test_a_whitelisted_call_next_to_a_guarded_one_does_not_excuse_it(self):
self.assertVerdict("tea logins list && tea issues list", BLOCK)
class TestUnparseableLinesFailClosed(GuardCase):
"""An unbalanced quote means the shell's reading and ours may differ. The
old substring test decides — it over-matches, and over-matching blocks."""
def test_an_unterminated_quote_around_a_call_still_blocks(self):
self.assertVerdict('tea issues list --state "open', BLOCK)
def test_an_unterminated_quote_with_no_call_is_still_allowed(self):
self.assertVerdict('echo "unterminated', ALLOW)
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
`wiki:` is a domain field, and the parser now agrees with the format.
python3 -m unittest discover -s tests -v
The bug: `references/format.md` put `wiki:` in the table of domain fields,
between `depends` and `origin`, and `issue.py` had never heard of it. The
field fell into `extra` and rendered with the foreign keys — sorted in beside
`branch`, `gitea`, `synced`, `url`, i.e. AFTER the sync fields, which the same
document forbids one line further down. A list written without brackets parsed
as a single string, and nothing could set the field but a text editor.
These tests pin the resolution: implemented in the domain, rendered among the
domain fields, parsed as a list in both forms, and reachable from the command
line. The layer rule rides along — a title is a name for a document, so the
field carries titles and this layer never resolves one.
"""
import os
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
sys.path.insert(0, ISSUE_SCRIPTS)
import issue # noqa: E402
TITLES = ["Simple Chains/Ideas/Chain core", "Simple Chains/Ideas/Transport"]
SYNCED = """\
---
id: wire-sqlc-appclick
state: open
labels: [type/task]
assignees: []
milestone: none
depends: [migrate-schema]
wiki: [Simple Chains/Ideas/Chain core]
origin: gitea
branch: feat/wire-sqlc
gitea: claude-skills/tea#42
synced: 2026-08-09T18:40:00Z
url: https://git.noodles.cam/claude-skills/tea/issues/42
---
# Wire sqlc into the appclick repo layer
## Summary
Тело роли не играет.
"""
class TestTheFieldIsInTheDomain(unittest.TestCase):
def test_it_is_a_domain_key_and_a_list_key(self):
self.assertIn("wiki", issue.DOMAIN_KEYS)
self.assertIn("wiki", issue.LIST_KEYS)
def test_it_renders_between_depends_and_origin(self):
"""`format.md` states the order and says domain fields render first.
The old behavior put it after the sync fields."""
order = issue.DOMAIN_KEYS
self.assertEqual(order[order.index("depends") + 1], "wiki")
self.assertEqual(order[order.index("wiki") + 1], "origin")
def test_it_survives_a_round_trip_among_the_domain_fields(self):
iss = issue.Issue.from_text(SYNCED, id="wire-sqlc-appclick")
self.assertEqual(iss.wiki, ["Simple Chains/Ideas/Chain core"])
self.assertNotIn("wiki", iss.extra)
text = iss.to_text()
keys = [line.split(":", 1)[0]
for line in text.splitlines()[1:]
if line != "---" and ":" in line]
keys = keys[:keys.index("origin") + 1]
self.assertEqual(keys[-3:], ["depends", "wiki", "origin"])
self.assertLess(keys.index("wiki"), keys.index("origin"))
again = issue.Issue.from_text(text, id="wire-sqlc-appclick")
self.assertEqual(again.wiki, iss.wiki)
def test_a_bracketless_list_is_still_a_list(self):
"""Without membership in LIST_KEYS this parsed as one string —
`wiki: A, B` became the single title "A, B"."""
text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]",
"wiki: %s" % ", ".join(TITLES))
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
def test_the_bracketed_form_parses_the_same_way(self):
text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]",
"wiki: [%s]" % ", ".join(TITLES))
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
def test_an_absent_field_is_an_empty_list_and_renders_as_one(self):
text = "\n".join(l for l in SYNCED.splitlines()
if not l.startswith("wiki:"))
iss = issue.Issue.from_text(text)
self.assertEqual(iss.wiki, [])
self.assertIn("wiki: []", iss.to_text())
def test_the_titles_are_carried_verbatim(self):
"""A title with a slash in it is one title — the slash is hierarchy
inside the name, not a path this layer walks."""
iss = issue.Issue(id="x", title="X", wiki=TITLES)
self.assertIn("wiki: [%s]" % ", ".join(TITLES), iss.to_text())
def test_the_domain_still_knows_nothing_about_a_wiki_it_could_reach(self):
"""The layer rule: titles only. No page path, no sub_url, no HTTP."""
with open(os.path.join(ISSUE_SCRIPTS, "issue.py")) as f:
body = f.read()
for banned in ("sub_url", "content_base64", "urllib"):
self.assertNotIn(banned, body)
imports = [l for l in body.splitlines()
if l.startswith("import ") or l.startswith("from ")]
self.assertNotIn("import subprocess", imports)
class TestIssueNewCanSetIt(unittest.TestCase):
"""The script run for real, in a throwaway store — never the developer's."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="tea-wiki-field-")
self.out = os.path.join(os.path.realpath(self._tmp.name), "issues")
self.addCleanup(self._tmp.cleanup)
def new(self, *args):
p = subprocess.run(
[sys.executable, os.path.join(ISSUE_SCRIPTS, "issue_new.py"),
"--type", "task", "--title", "Write the chain core up",
"--out", self.out] + list(args),
capture_output=True, text=True)
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
with open(os.path.join(self.out, "write-the-chain-core-up.md")) as f:
return f.read()
def test_the_flag_repeats_into_a_list(self):
text = self.new("--wiki", TITLES[0], "--wiki", TITLES[1])
self.assertIn("wiki: [%s]" % ", ".join(TITLES), text)
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
def test_without_the_flag_the_field_is_present_and_empty(self):
self.assertIn("wiki: []", self.new())
if __name__ == "__main__":
unittest.main()