taktcli 0.1.0__tar.gz
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.
- taktcli-0.1.0/PKG-INFO +117 -0
- taktcli-0.1.0/README.md +101 -0
- taktcli-0.1.0/pyproject.toml +31 -0
- taktcli-0.1.0/setup.cfg +4 -0
- taktcli-0.1.0/taktcli/__init__.py +6 -0
- taktcli-0.1.0/taktcli/__main__.py +53 -0
- taktcli-0.1.0/taktcli/auth.py +104 -0
- taktcli-0.1.0/taktcli/commands/__init__.py +68 -0
- taktcli-0.1.0/taktcli/commands/_exec.py +85 -0
- taktcli-0.1.0/taktcli/commands/_run.py +51 -0
- taktcli-0.1.0/taktcli/commands/auth.py +206 -0
- taktcli-0.1.0/taktcli/commands/file.py +183 -0
- taktcli-0.1.0/taktcli/commands/gql.py +66 -0
- taktcli-0.1.0/taktcli/commands/hotpath.py +150 -0
- taktcli-0.1.0/taktcli/commands/label.py +89 -0
- taktcli-0.1.0/taktcli/commands/notif.py +64 -0
- taktcli-0.1.0/taktcli/commands/project.py +111 -0
- taktcli-0.1.0/taktcli/commands/schema.py +56 -0
- taktcli-0.1.0/taktcli/commands/task.py +418 -0
- taktcli-0.1.0/taktcli/commands/watch.py +51 -0
- taktcli-0.1.0/taktcli/commands/wiki.py +308 -0
- taktcli-0.1.0/taktcli/commands/workspace.py +59 -0
- taktcli-0.1.0/taktcli/errors.py +76 -0
- taktcli-0.1.0/taktcli/introspection.py +253 -0
- taktcli-0.1.0/taktcli/io.py +150 -0
- taktcli-0.1.0/taktcli/transport.py +136 -0
- taktcli-0.1.0/taktcli/watch.py +169 -0
- taktcli-0.1.0/taktcli.egg-info/PKG-INFO +117 -0
- taktcli-0.1.0/taktcli.egg-info/SOURCES.txt +48 -0
- taktcli-0.1.0/taktcli.egg-info/dependency_links.txt +1 -0
- taktcli-0.1.0/taktcli.egg-info/entry_points.txt +2 -0
- taktcli-0.1.0/taktcli.egg-info/requires.txt +6 -0
- taktcli-0.1.0/taktcli.egg-info/top_level.txt +1 -0
- taktcli-0.1.0/tests/test_auth.py +75 -0
- taktcli-0.1.0/tests/test_auth_commands.py +246 -0
- taktcli-0.1.0/tests/test_cli.py +81 -0
- taktcli-0.1.0/tests/test_file.py +187 -0
- taktcli-0.1.0/tests/test_gql.py +115 -0
- taktcli-0.1.0/tests/test_hotpath.py +157 -0
- taktcli-0.1.0/tests/test_introspection.py +175 -0
- taktcli-0.1.0/tests/test_io.py +122 -0
- taktcli-0.1.0/tests/test_label.py +100 -0
- taktcli-0.1.0/tests/test_notif.py +61 -0
- taktcli-0.1.0/tests/test_project.py +106 -0
- taktcli-0.1.0/tests/test_schema.py +107 -0
- taktcli-0.1.0/tests/test_task.py +278 -0
- taktcli-0.1.0/tests/test_transport.py +123 -0
- taktcli-0.1.0/tests/test_watch.py +287 -0
- taktcli-0.1.0/tests/test_wiki.py +219 -0
- taktcli-0.1.0/tests/test_workspace.py +65 -0
taktcli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taktcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
|
|
5
|
+
Author: Takt
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://takt.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/muzhig/takt
|
|
9
|
+
Keywords: takt,graphql,cli,agents
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: watch
|
|
13
|
+
Requires-Dist: websockets>=12.0; extra == "watch"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# taktcli
|
|
18
|
+
|
|
19
|
+
Authorized GraphQL CLI for [Takt](https://takt.sh) — the full GraphQL surface
|
|
20
|
+
from the shell, for agents and humans. Package and console-script are both
|
|
21
|
+
named `taktcli` (bare `takt` is taken on PyPI).
|
|
22
|
+
|
|
23
|
+
The core is **stdlib-only** (`urllib` + `argparse` + `json`): `argv → GraphQL-over-HTTP`.
|
|
24
|
+
It sidesteps the MCP client-serialization bug class entirely — argv strings can't
|
|
25
|
+
be double-JSON-encoded. See PRD 002 in the Takt wiki (`takt/dev/prd-002-taktcli`).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install taktcli # stdlib-only core
|
|
31
|
+
pip install 'taktcli[watch]' # + websockets, enables `taktcli watch`
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
From a checkout (monorepo subdir):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e cli/
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Auth (PRD §6)
|
|
41
|
+
|
|
42
|
+
Resolution order, simplest-wins:
|
|
43
|
+
|
|
44
|
+
- **Token:** `TAKT_TOKEN` env → `~/.takt/credentials.json` → error `Run: taktcli login` (exit 3)
|
|
45
|
+
- **URL:** `TAKT_URL` env → `url` in the credentials file → default `https://api.takt.sh/graphql`
|
|
46
|
+
|
|
47
|
+
`~/.takt/credentials.json` is plain JSON (no YAML dependency):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "token": "<jwt>", "url": "https://api.takt.sh/graphql" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Login
|
|
54
|
+
|
|
55
|
+
`taktcli login` runs the OAuth 2.0 device-authorization grant: it prints a
|
|
56
|
+
verification URL + user code, polls until you approve in the browser, then
|
|
57
|
+
writes `{token, url}` to `~/.takt/credentials.json` (mode `0600`).
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
taktcli login # default endpoint
|
|
61
|
+
taktcli login --url http://localhost:8454/graphql # override the endpoint
|
|
62
|
+
taktcli logout # remove the stored credential
|
|
63
|
+
taktcli whoami # nickname + claims + active source
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--url` (highest) → `TAKT_URL` env → default decides where `login` authenticates.
|
|
67
|
+
`whoami` reports which credential source is active (`TAKT_TOKEN` env vs the file).
|
|
68
|
+
|
|
69
|
+
## Exit codes (PRD §4.3)
|
|
70
|
+
|
|
71
|
+
| Code | Meaning |
|
|
72
|
+
|------|---------|
|
|
73
|
+
| `0` | success |
|
|
74
|
+
| `1` | GraphQL error (server reached; `errors[]` non-empty) |
|
|
75
|
+
| `2` | transport error (DNS/TCP/TLS/HTTP non-2xx/timeout) |
|
|
76
|
+
| `3` | config or usage error (no token, bad URL, unknown verb, missing flag) |
|
|
77
|
+
|
|
78
|
+
`extensions.code` (e.g. `LEASE_EXPIRED`, `ALREADY_CLAIMED`) is preserved in
|
|
79
|
+
error output so loops can branch on it.
|
|
80
|
+
|
|
81
|
+
## Shared I/O flags (PRD §4)
|
|
82
|
+
|
|
83
|
+
- `--var KEY=VALUE` (repeatable; value parsed as JSON when valid, else string)
|
|
84
|
+
- `--vars JSON` / `--vars-file PATH` (`-` = stdin) — bulk variables
|
|
85
|
+
- `--field-file NAME=PATH` (`NAME=-` = stdin) — inject a file's contents as a
|
|
86
|
+
variable; the canonical path for large markdown
|
|
87
|
+
- `--raw` — full envelope `{data, errors, extensions}`; default is pretty JSON of `.data`
|
|
88
|
+
- `--compact` — single-line JSON
|
|
89
|
+
|
|
90
|
+
Variable precedence (lowest → highest): `--vars-file` < `--vars` < `--var` < `--field-file`.
|
|
91
|
+
|
|
92
|
+
## Status
|
|
93
|
+
|
|
94
|
+
Implemented: **A** (foundation — package skeleton, transport, auth/URL
|
|
95
|
+
resolution, shared I/O plumbing, CI/release), **B** (`gql` + `schema`
|
|
96
|
+
passthrough), **C** (hot-path aliases + `task …`), **D** (`wiki …` +
|
|
97
|
+
`workspace …`), **G** (`login`/`logout`/`whoami`), and **H** (`label …`,
|
|
98
|
+
`notif …`, `project …`). Curated verbs each map to a GraphQL op via
|
|
99
|
+
`transport.execute`; the `wiki`/`workspace` verbs take a global `--wiki SLUG`
|
|
100
|
+
(default `$TAKT_WIKI`) and stream big markdown through
|
|
101
|
+
`--content-file`/`--old-file`/`--new-file` (`-` = stdin).
|
|
102
|
+
|
|
103
|
+
`label create` is CLI-only (the MCP server never wrapped `createLabel`, but the
|
|
104
|
+
GraphQL mutation exists). `label update`/`delete` are keyed by **name** and
|
|
105
|
+
resolve the id client-side via the `labels` query, mirroring the MCP tools.
|
|
106
|
+
`project rename` is name-only — slug changes are blocked on backend
|
|
107
|
+
[[takt-304]]. Per the [[takt-83]] guardrail there is **no** claims/permission
|
|
108
|
+
verb anywhere in the surface.
|
|
109
|
+
|
|
110
|
+
The remaining verb groups are stubs — running one exits 3 with a pointer to its
|
|
111
|
+
implementing subtask (E media/file, F watch, J delete-result).
|
|
112
|
+
|
|
113
|
+
## Release
|
|
114
|
+
|
|
115
|
+
Push a `cli-v*` tag (e.g. `cli-v0.1.0`) — `.github/workflows/cli.yaml` builds
|
|
116
|
+
`cli/` and OIDC trusted-publishes to PyPI. The tag must match the version in
|
|
117
|
+
`cli/pyproject.toml` (CI asserts this).
|
taktcli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# taktcli
|
|
2
|
+
|
|
3
|
+
Authorized GraphQL CLI for [Takt](https://takt.sh) — the full GraphQL surface
|
|
4
|
+
from the shell, for agents and humans. Package and console-script are both
|
|
5
|
+
named `taktcli` (bare `takt` is taken on PyPI).
|
|
6
|
+
|
|
7
|
+
The core is **stdlib-only** (`urllib` + `argparse` + `json`): `argv → GraphQL-over-HTTP`.
|
|
8
|
+
It sidesteps the MCP client-serialization bug class entirely — argv strings can't
|
|
9
|
+
be double-JSON-encoded. See PRD 002 in the Takt wiki (`takt/dev/prd-002-taktcli`).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install taktcli # stdlib-only core
|
|
15
|
+
pip install 'taktcli[watch]' # + websockets, enables `taktcli watch`
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
From a checkout (monorepo subdir):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e cli/
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Auth (PRD §6)
|
|
25
|
+
|
|
26
|
+
Resolution order, simplest-wins:
|
|
27
|
+
|
|
28
|
+
- **Token:** `TAKT_TOKEN` env → `~/.takt/credentials.json` → error `Run: taktcli login` (exit 3)
|
|
29
|
+
- **URL:** `TAKT_URL` env → `url` in the credentials file → default `https://api.takt.sh/graphql`
|
|
30
|
+
|
|
31
|
+
`~/.takt/credentials.json` is plain JSON (no YAML dependency):
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{ "token": "<jwt>", "url": "https://api.takt.sh/graphql" }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Login
|
|
38
|
+
|
|
39
|
+
`taktcli login` runs the OAuth 2.0 device-authorization grant: it prints a
|
|
40
|
+
verification URL + user code, polls until you approve in the browser, then
|
|
41
|
+
writes `{token, url}` to `~/.takt/credentials.json` (mode `0600`).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
taktcli login # default endpoint
|
|
45
|
+
taktcli login --url http://localhost:8454/graphql # override the endpoint
|
|
46
|
+
taktcli logout # remove the stored credential
|
|
47
|
+
taktcli whoami # nickname + claims + active source
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`--url` (highest) → `TAKT_URL` env → default decides where `login` authenticates.
|
|
51
|
+
`whoami` reports which credential source is active (`TAKT_TOKEN` env vs the file).
|
|
52
|
+
|
|
53
|
+
## Exit codes (PRD §4.3)
|
|
54
|
+
|
|
55
|
+
| Code | Meaning |
|
|
56
|
+
|------|---------|
|
|
57
|
+
| `0` | success |
|
|
58
|
+
| `1` | GraphQL error (server reached; `errors[]` non-empty) |
|
|
59
|
+
| `2` | transport error (DNS/TCP/TLS/HTTP non-2xx/timeout) |
|
|
60
|
+
| `3` | config or usage error (no token, bad URL, unknown verb, missing flag) |
|
|
61
|
+
|
|
62
|
+
`extensions.code` (e.g. `LEASE_EXPIRED`, `ALREADY_CLAIMED`) is preserved in
|
|
63
|
+
error output so loops can branch on it.
|
|
64
|
+
|
|
65
|
+
## Shared I/O flags (PRD §4)
|
|
66
|
+
|
|
67
|
+
- `--var KEY=VALUE` (repeatable; value parsed as JSON when valid, else string)
|
|
68
|
+
- `--vars JSON` / `--vars-file PATH` (`-` = stdin) — bulk variables
|
|
69
|
+
- `--field-file NAME=PATH` (`NAME=-` = stdin) — inject a file's contents as a
|
|
70
|
+
variable; the canonical path for large markdown
|
|
71
|
+
- `--raw` — full envelope `{data, errors, extensions}`; default is pretty JSON of `.data`
|
|
72
|
+
- `--compact` — single-line JSON
|
|
73
|
+
|
|
74
|
+
Variable precedence (lowest → highest): `--vars-file` < `--vars` < `--var` < `--field-file`.
|
|
75
|
+
|
|
76
|
+
## Status
|
|
77
|
+
|
|
78
|
+
Implemented: **A** (foundation — package skeleton, transport, auth/URL
|
|
79
|
+
resolution, shared I/O plumbing, CI/release), **B** (`gql` + `schema`
|
|
80
|
+
passthrough), **C** (hot-path aliases + `task …`), **D** (`wiki …` +
|
|
81
|
+
`workspace …`), **G** (`login`/`logout`/`whoami`), and **H** (`label …`,
|
|
82
|
+
`notif …`, `project …`). Curated verbs each map to a GraphQL op via
|
|
83
|
+
`transport.execute`; the `wiki`/`workspace` verbs take a global `--wiki SLUG`
|
|
84
|
+
(default `$TAKT_WIKI`) and stream big markdown through
|
|
85
|
+
`--content-file`/`--old-file`/`--new-file` (`-` = stdin).
|
|
86
|
+
|
|
87
|
+
`label create` is CLI-only (the MCP server never wrapped `createLabel`, but the
|
|
88
|
+
GraphQL mutation exists). `label update`/`delete` are keyed by **name** and
|
|
89
|
+
resolve the id client-side via the `labels` query, mirroring the MCP tools.
|
|
90
|
+
`project rename` is name-only — slug changes are blocked on backend
|
|
91
|
+
[[takt-304]]. Per the [[takt-83]] guardrail there is **no** claims/permission
|
|
92
|
+
verb anywhere in the surface.
|
|
93
|
+
|
|
94
|
+
The remaining verb groups are stubs — running one exits 3 with a pointer to its
|
|
95
|
+
implementing subtask (E media/file, F watch, J delete-result).
|
|
96
|
+
|
|
97
|
+
## Release
|
|
98
|
+
|
|
99
|
+
Push a `cli-v*` tag (e.g. `cli-v0.1.0`) — `.github/workflows/cli.yaml` builds
|
|
100
|
+
`cli/` and OIDC trusted-publishes to PyPI. The tag must match the version in
|
|
101
|
+
`cli/pyproject.toml` (CI asserts this).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "taktcli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Takt" }]
|
|
9
|
+
keywords = ["takt", "graphql", "cli", "agents"]
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
watch = ["websockets>=12.0"]
|
|
14
|
+
dev = ["pytest>=8.0"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
taktcli = "taktcli.__main__:main"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://takt.sh"
|
|
21
|
+
Repository = "https://github.com/muzhig/takt"
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
include = ["taktcli*"]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools>=75"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
taktcli-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""taktcli entry point — argparse dispatch (PRD 002 §3, §4.3).
|
|
2
|
+
|
|
3
|
+
Resolves the command tree, runs the selected handler, and maps typed errors to
|
|
4
|
+
the exit-code contract. Usage/parse errors exit 3 (config/usage), not argparse's
|
|
5
|
+
default 2 (which the contract reserves for transport failures).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__, commands
|
|
12
|
+
from .errors import TaktCliError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaktArgumentParser(argparse.ArgumentParser):
|
|
16
|
+
"""ArgumentParser whose parse/usage errors exit 3, per the CLI contract."""
|
|
17
|
+
|
|
18
|
+
def error(self, message):
|
|
19
|
+
self.print_usage(sys.stderr)
|
|
20
|
+
self.exit(3, f"{self.prog}: error: {message}\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> TaktArgumentParser:
|
|
24
|
+
"""Build the top-level parser with all noun groups and verbs registered."""
|
|
25
|
+
parser = TaktArgumentParser(
|
|
26
|
+
prog="taktcli",
|
|
27
|
+
description="Authorized GraphQL CLI for Takt — full GraphQL surface from the shell.",
|
|
28
|
+
epilog="Run 'taktcli <noun> --help' for a group's verbs. Most verbs land in subtasks B-J.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"taktcli {__version__}")
|
|
31
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>", required=True)
|
|
32
|
+
commands.register(subparsers)
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv=None) -> int:
|
|
37
|
+
"""Parse argv, dispatch to the handler, and return the process exit code."""
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
handler = getattr(args, "func", None)
|
|
41
|
+
if handler is None:
|
|
42
|
+
parser.print_help(sys.stderr)
|
|
43
|
+
return 3
|
|
44
|
+
try:
|
|
45
|
+
result = handler(args)
|
|
46
|
+
except TaktCliError as exc:
|
|
47
|
+
print(exc.render(), file=sys.stderr)
|
|
48
|
+
return exc.exit_code
|
|
49
|
+
return int(result or 0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
sys.exit(main())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Auth + URL resolution (PRD 002 §6).
|
|
2
|
+
|
|
3
|
+
Resolution order (simplest-wins):
|
|
4
|
+
|
|
5
|
+
Token: ``TAKT_TOKEN`` env -> ``~/.takt/credentials.json`` -> error (exit 3)
|
|
6
|
+
URL: ``TAKT_URL`` env -> ``url`` in credentials file -> default
|
|
7
|
+
|
|
8
|
+
Full ``taktcli login`` (device-auth) is subtask G2; this module only reads
|
|
9
|
+
the JSON credential store and resolves the active token/URL.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .errors import ConfigError
|
|
17
|
+
|
|
18
|
+
DEFAULT_URL = "https://api.takt.sh/graphql"
|
|
19
|
+
|
|
20
|
+
LOGIN_HINT = "No Takt credentials found. Run: taktcli login"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def credentials_path() -> Path:
|
|
24
|
+
"""Location of the JSON credential store (``~/.takt/credentials.json``)."""
|
|
25
|
+
return Path.home() / ".takt" / "credentials.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_credentials(path: Path | None = None) -> dict:
|
|
29
|
+
"""Read the JSON credential store. Missing file -> ``{}``.
|
|
30
|
+
|
|
31
|
+
A present-but-corrupt file is a configuration error (exit 3), not a
|
|
32
|
+
silent empty read — surface it so the user can fix the file.
|
|
33
|
+
"""
|
|
34
|
+
path = path or credentials_path()
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return {}
|
|
37
|
+
raw = path.read_text()
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(raw)
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise ConfigError(f"Corrupt credentials file at {path}: {exc}") from exc
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
raise ConfigError(f"Credentials file at {path} must be a JSON object")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_token(credentials: dict | None = None) -> str:
|
|
48
|
+
"""Resolve the bearer token: env -> file -> error (exit 3)."""
|
|
49
|
+
env_token = os.environ.get("TAKT_TOKEN")
|
|
50
|
+
if env_token:
|
|
51
|
+
return env_token
|
|
52
|
+
creds = load_credentials() if credentials is None else credentials
|
|
53
|
+
token = creds.get("token")
|
|
54
|
+
if token:
|
|
55
|
+
return token
|
|
56
|
+
raise ConfigError(LOGIN_HINT)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_url(credentials: dict | None = None) -> str:
|
|
60
|
+
"""Resolve the GraphQL endpoint: env -> file -> default."""
|
|
61
|
+
env_url = os.environ.get("TAKT_URL")
|
|
62
|
+
if env_url:
|
|
63
|
+
return env_url
|
|
64
|
+
creds = load_credentials() if credentials is None else credentials
|
|
65
|
+
return creds.get("url") or DEFAULT_URL
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_auth() -> tuple[str, str]:
|
|
69
|
+
"""Resolve ``(url, token)`` in one pass, reading the file at most once."""
|
|
70
|
+
creds = load_credentials()
|
|
71
|
+
return resolve_url(creds), resolve_token(creds)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def token_source() -> str | None:
|
|
75
|
+
"""Describe where the active token comes from, or ``None`` if there is none.
|
|
76
|
+
|
|
77
|
+
Mirrors :func:`resolve_token`'s precedence: env wins over the file.
|
|
78
|
+
"""
|
|
79
|
+
if os.environ.get("TAKT_TOKEN"):
|
|
80
|
+
return "TAKT_TOKEN env"
|
|
81
|
+
if load_credentials().get("token"):
|
|
82
|
+
return f"credentials file ({credentials_path()})"
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_credentials(token: str, url: str, path: Path | None = None) -> Path:
|
|
87
|
+
"""Persist ``{token, url}`` to the JSON store, creating ``~/.takt`` as 0700.
|
|
88
|
+
|
|
89
|
+
The file is written 0600 — it holds a bearer token.
|
|
90
|
+
"""
|
|
91
|
+
path = path or credentials_path()
|
|
92
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
93
|
+
path.write_text(json.dumps({"token": token, "url": url}, indent=2) + "\n")
|
|
94
|
+
path.chmod(0o600)
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clear_credentials(path: Path | None = None) -> bool:
|
|
99
|
+
"""Remove the credential store. Returns ``True`` if a file was deleted."""
|
|
100
|
+
path = path or credentials_path()
|
|
101
|
+
if path.exists():
|
|
102
|
+
path.unlink()
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Command surface for taktcli (PRD 002 §3).
|
|
2
|
+
|
|
3
|
+
This module declares the noun/verb tree. Implemented surfaces register their own
|
|
4
|
+
parsers via per-module ``register`` functions; the remaining groups are stubs
|
|
5
|
+
(running one exits 3 with a pointer to the implementing subtask) until they land.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ..errors import ConfigError
|
|
9
|
+
from ..io import add_shared_io_args
|
|
10
|
+
from . import auth, file, gql, hotpath, label, notif, project, schema, task, watch, wiki, workspace
|
|
11
|
+
|
|
12
|
+
NOUN_GROUPS = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _stub(path: str, subtask: str):
|
|
16
|
+
def handler(args):
|
|
17
|
+
raise ConfigError(
|
|
18
|
+
f"`taktcli {path}` is not implemented yet (subtask {subtask})."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return handler
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _add_leaf(subparsers, name: str, spec, path_prefix: str) -> None:
|
|
25
|
+
help_text = spec["help"] if isinstance(spec, dict) else spec
|
|
26
|
+
subtask = spec.get("subtask", "?") if isinstance(spec, dict) else "?"
|
|
27
|
+
leaf = subparsers.add_parser(name, help=help_text, description=help_text)
|
|
28
|
+
path = f"{path_prefix}{name}".strip()
|
|
29
|
+
if isinstance(spec, dict) and spec.get("io"):
|
|
30
|
+
add_shared_io_args(leaf)
|
|
31
|
+
else:
|
|
32
|
+
leaf.add_argument("args", nargs="*", help="(stub — see implementing subtask)")
|
|
33
|
+
leaf.set_defaults(func=_stub(path, subtask))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _add_group(subparsers, name: str, spec: dict, path_prefix: str, parent_subtask: str = "?") -> None:
|
|
37
|
+
group = subparsers.add_parser(name, help=spec["help"], description=spec["help"])
|
|
38
|
+
sub = group.add_subparsers(dest=f"{name}_verb", metavar="<verb>", required=True)
|
|
39
|
+
group_subtask = spec.get("subtask", parent_subtask)
|
|
40
|
+
for verb, verb_spec in spec["verbs"].items():
|
|
41
|
+
path = f"{path_prefix}{name} "
|
|
42
|
+
if isinstance(verb_spec, dict) and "verbs" in verb_spec:
|
|
43
|
+
_add_group(sub, verb, verb_spec, path, group_subtask)
|
|
44
|
+
else:
|
|
45
|
+
leaf_spec = (
|
|
46
|
+
{"help": verb_spec, "subtask": group_subtask}
|
|
47
|
+
if isinstance(verb_spec, str)
|
|
48
|
+
else {**verb_spec, "subtask": verb_spec.get("subtask", group_subtask)}
|
|
49
|
+
)
|
|
50
|
+
_add_leaf(sub, verb, leaf_spec, path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register(subparsers) -> None:
|
|
54
|
+
"""Register every implemented verb plus the remaining stub groups."""
|
|
55
|
+
gql.register(subparsers)
|
|
56
|
+
schema.register(subparsers)
|
|
57
|
+
auth.register(subparsers)
|
|
58
|
+
hotpath.register(subparsers)
|
|
59
|
+
task.register(subparsers)
|
|
60
|
+
file.register(subparsers)
|
|
61
|
+
wiki.register(subparsers)
|
|
62
|
+
workspace.register(subparsers)
|
|
63
|
+
label.register(subparsers)
|
|
64
|
+
notif.register(subparsers)
|
|
65
|
+
project.register(subparsers)
|
|
66
|
+
watch.register(subparsers)
|
|
67
|
+
for name, spec in NOUN_GROUPS.items():
|
|
68
|
+
_add_group(subparsers, name, spec, "")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Shared execution + I/O helpers for the task verbs and hot-path aliases.
|
|
2
|
+
|
|
3
|
+
Every verb in subtask C builds a GraphQL document plus a variables dict and runs
|
|
4
|
+
it through :func:`run_operation` (or :func:`fetch` when it needs the raw envelope
|
|
5
|
+
back). Auth, transport, output formatting, and the exit-code contract are all
|
|
6
|
+
funnelled through here so the verbs stay declarative (PRD 002 §3.3, §3.4, §4).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from ..auth import resolve_auth
|
|
14
|
+
from ..errors import ConfigError
|
|
15
|
+
from ..io import format_output, read_path
|
|
16
|
+
from ..transport import execute
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_output_args(parser: argparse.ArgumentParser) -> None:
|
|
20
|
+
"""Attach the ``--raw``/``--compact`` output flags to a verb parser."""
|
|
21
|
+
group = parser.add_argument_group("output")
|
|
22
|
+
group.add_argument(
|
|
23
|
+
"--raw",
|
|
24
|
+
action="store_true",
|
|
25
|
+
help="print the full GraphQL envelope {data, errors, extensions}",
|
|
26
|
+
)
|
|
27
|
+
group.add_argument(
|
|
28
|
+
"--compact",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="single-line JSON output (pipe-friendly)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch(document: str, variables: dict) -> dict:
|
|
35
|
+
"""Resolve auth, run the operation, and return the full GraphQL envelope."""
|
|
36
|
+
url, token = resolve_auth()
|
|
37
|
+
return execute(document, variables, url=url, token=token)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_operation(args, document: str, variables: dict) -> int:
|
|
41
|
+
"""Execute ``document`` and print the formatted envelope; return exit 0."""
|
|
42
|
+
envelope = fetch(document, variables)
|
|
43
|
+
print(format_output(envelope, raw=getattr(args, "raw", False), compact=getattr(args, "compact", False)))
|
|
44
|
+
sys.stdout.flush()
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_lease(args) -> str | None:
|
|
49
|
+
"""Resolve a lease id: ``--lease`` flag, else ``$TAKT_LEASE``, else None."""
|
|
50
|
+
lease = getattr(args, "lease", None)
|
|
51
|
+
if lease:
|
|
52
|
+
return lease
|
|
53
|
+
return os.environ.get("TAKT_LEASE") or None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def text_from_flags(inline, path, inline_flag: str, file_flag: str):
|
|
57
|
+
"""Resolve a text value from an inline flag or a ``*-file`` path.
|
|
58
|
+
|
|
59
|
+
``path`` of ``-`` reads stdin. Passing both is a usage error (exit 3).
|
|
60
|
+
Returns ``None`` when neither is provided so callers can decide if the
|
|
61
|
+
field is required.
|
|
62
|
+
"""
|
|
63
|
+
if inline is not None and path is not None:
|
|
64
|
+
raise ConfigError(f"pass either {inline_flag} or {file_flag}, not both")
|
|
65
|
+
if path is not None:
|
|
66
|
+
return read_path(path)
|
|
67
|
+
return inline
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_operation(name: str, field: str, arg_specs: list, selection: str, *, op_type: str = "query"):
|
|
71
|
+
"""Assemble a single-field GraphQL operation from provided arguments.
|
|
72
|
+
|
|
73
|
+
``arg_specs`` is a list of ``(var_name, gql_type, arg_name, value)`` tuples,
|
|
74
|
+
one per *provided* argument — only these are declared and passed, so the
|
|
75
|
+
document never references an unused (and therefore illegal) variable. The
|
|
76
|
+
``selection`` string includes its own surrounding braces.
|
|
77
|
+
"""
|
|
78
|
+
decls = ", ".join(f"${var}: {gql_type}" for var, gql_type, _, _ in arg_specs)
|
|
79
|
+
decl_str = f"({decls})" if decls else ""
|
|
80
|
+
field_args = ", ".join(f"{arg}: ${var}" for var, _, arg, _ in arg_specs)
|
|
81
|
+
field_args_str = f"({field_args})" if field_args else ""
|
|
82
|
+
selection_str = f" {selection}" if selection else ""
|
|
83
|
+
document = f"{op_type} {name}{decl_str} {{ {field}{field_args_str}{selection_str} }}"
|
|
84
|
+
variables = {var: value for var, _, _, value in arg_specs}
|
|
85
|
+
return document, variables
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared helpers for curated noun/verb commands (PRD 002 §3.4-§3.8).
|
|
2
|
+
|
|
3
|
+
Curated verbs map a fixed GraphQL document plus a small set of typed flags to
|
|
4
|
+
``transport.execute`` and print the result through the §4.2 output contract.
|
|
5
|
+
This module holds the two pieces every curated verb repeats: running the op and
|
|
6
|
+
resolving an inline-or-file (``-`` = stdin) text argument.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from ..auth import resolve_auth
|
|
12
|
+
from ..errors import ConfigError
|
|
13
|
+
from ..io import format_output, read_path
|
|
14
|
+
from ..transport import execute
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_op(args, document: str, variables: dict) -> int:
|
|
18
|
+
"""Execute a GraphQL document and print the formatted result."""
|
|
19
|
+
url, token = resolve_auth()
|
|
20
|
+
envelope = execute(document, variables, url=url, token=token)
|
|
21
|
+
print(format_output(envelope, raw=getattr(args, "raw", False), compact=getattr(args, "compact", False)))
|
|
22
|
+
sys.stdout.flush()
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_data(document: str, variables: dict) -> dict:
|
|
27
|
+
"""Execute a GraphQL document and return its unwrapped ``.data`` payload.
|
|
28
|
+
|
|
29
|
+
For curated verbs that need a server lookup before their real op (e.g.
|
|
30
|
+
resolving a label name to its id) without printing the intermediate result.
|
|
31
|
+
"""
|
|
32
|
+
url, token = resolve_auth()
|
|
33
|
+
envelope = execute(document, variables, url=url, token=token)
|
|
34
|
+
return (envelope or {}).get("data") or {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_text(inline, file_path, name: str, *, required: bool = False):
|
|
38
|
+
"""Resolve a ``--<name>`` / ``--<name>-file`` (`-` = stdin) pair.
|
|
39
|
+
|
|
40
|
+
Passing both is a usage error; passing neither yields ``None`` unless
|
|
41
|
+
``required``, in which case it is a usage error too.
|
|
42
|
+
"""
|
|
43
|
+
if inline is not None and file_path is not None:
|
|
44
|
+
raise ConfigError(f"pass either --{name} or --{name}-file, not both")
|
|
45
|
+
if file_path is not None:
|
|
46
|
+
return read_path(file_path)
|
|
47
|
+
if inline is not None:
|
|
48
|
+
return inline
|
|
49
|
+
if required:
|
|
50
|
+
raise ConfigError(f"--{name} or --{name}-file is required")
|
|
51
|
+
return None
|