Metadata-Version: 2.4
Name: taktcli
Version: 0.7.0
Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
Author: Takt
License: MIT
Project-URL: Homepage, https://takt.sh
Project-URL: Repository, https://github.com/muzhig/takt
Keywords: takt,graphql,cli,agents
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Provides-Extra: watch
Requires-Dist: websockets>=12.0; extra == "watch"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"

# taktcli

**Authorized GraphQL CLI for [Takt](https://takt.sh)** — the full Takt GraphQL
surface from the shell, for agents and humans. Everything the MCP server does,
**and more**.

The core is **stdlib-only** (`urllib` + `argparse` + `json`): a thin
`argv → GraphQL-over-HTTP` client. Because arguments arrive as `argv` strings, it
structurally sidesteps the MCP client-serialization bug class (arrays-as-JSON-
strings, double-encoded results, the empty-string-drops-all-args bug) — an `argv`
string simply can't be double-JSON-encoded.

The package and console-script are both named `taktcli` (bare `takt` is taken on
PyPI).

## Install

```bash
pip install taktcli            # stdlib-only core
pip install 'taktcli[watch]'   # + websockets, enables `taktcli watch`
pip install -e cli/            # from a monorepo checkout
```

Maestro/harness images ship `pip install 'taktcli[watch]'` — `taktcli watch` is
the supported replacement for the retired `tools/wait_for_task.py`.

## Quickstart

```bash
taktcli login                          # browser approval, stores a self-refreshing profile
taktcli whoami                         # subject, owner, workspace, expiry, credential source
taktcli board --project takt           # show the board
taktcli claim takt-42                  # READY → RUNNING, prints leaseId
taktcli run takt-42                    # heartbeat the lease
taktcli done takt-42                   # mark DONE
```

`taktcli --help` (or `taktcli help`) lists every command; `taktcli <noun> --help`
lists a group's verbs.

## Auth

Token resolution, simplest-wins:

1. **`TAKT_TOKEN` env** → used verbatim and never refreshed. This is what harness
   containers are stuffed with; env always wins over any stored credential.
2. Else the selected **profile** in `~/.takt/credentials.json`, written by
   `taktcli login`. The profile is `--profile NAME` → `TAKT_PROFILE` → the
   store's current profile → `default`.
3. Else error: `No Takt credentials found. Run: taktcli login` (exit `3`).

URL resolution mirrors it: `TAKT_URL` env → the profile's URL → default
`https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by swapping the
scheme (`https → wss`).

`taktcli login` runs the OAuth 2.0 **device grant** (RFC 8628). It prints
`Open <url> and enter <code>` to stderr, opens that page in your browser with the
code filled in (unless `--no-browser`, or there is no display), and waits for you
to approve. `--as <agent>` preselects an agent you own on the approval page, so
the CLI acts as that agent while you stay its owner.

```bash
taktcli login                                       # you, profile "default"
taktcli login --as claude-code --profile claude-code
taktcli login --no-browser                          # headless: just print the URL + code
taktcli login --url http://localhost:8454/graphql   # another server
taktcli whoami                                      # subject, owner, client, workspace, expiry, source
taktcli profile list                                # stored profiles (no secrets)
taktcli profile use claude-code
taktcli logout                                      # revoke the grant, then delete the profile
```

The stored grant refreshes itself: an access token lasts an hour and is renewed
60 seconds before it expires (or once after the server rejects it), and the grant
lives as long as it is used at least once every 90 days. Refreshes take a lock on
`~/.takt/credentials.lock`, so parallel `taktcli` and `watch` processes share one
rotation. `logout` revokes the grant on the server before deleting it locally;
`logout --force` deletes even when the server can't be reached.

The file is mode `0600` in a `0700` directory and is replaced atomically;
taktcli refuses to read it through a symlink or with group/world permissions. A
`{ "token", "url" }` file from taktcli ≤ 0.6 keeps working until your next
`login` rewrites it.

`taktcli` adds **no** privilege surface: the token never exceeds what the
approving human can grant, there is no claims/permission verb anywhere, and admin
ops are reachable via `gql` only if the token already holds the `admin` claim.
Bots and harness images keep using `TAKT_TOKEN`.

## Passthrough — reaches 100% of GraphQL

`taktcli gql` + `taktcli schema` are a complete superset of every MCP tool: any
GraphQL op MCP never wrapped is reachable here.

```bash
taktcli gql -q 'query { me { nickname claims } }'
taktcli gql -f query.graphql --var id=takt-42 --field-file description=desc.md
taktcli schema --sdl              # SDL introspection (--json for JSON)
```

Big markdown flows through `--field-file NAME=PATH` (`NAME=-` = stdin) — this
sidesteps shell quoting **and** the empty-string-args bug class.

### Shared I/O flags

- `--var KEY=VALUE` (repeatable; value parsed as JSON when valid, else string)
- `--vars JSON` / `--vars-file PATH` (`-` = stdin) — bulk variables
- `--field-file NAME=PATH` (`NAME=-` = stdin) — inject a file's contents as a
  variable; the canonical path for large markdown
- `--raw` — full `{data, errors, extensions}` envelope (default is pretty `.data`)
- `--compact` — single-line JSON

Variable precedence (lowest → highest):
`--vars-file` < `--vars` < `--var` < `--field-file`.

## Command surface

The CLI uses a **positional namespace** — `taktcli <noun> <verb> [args]` — so the
entity is always the first token. This fixes the MCP `create_wiki` (Wiki) vs
`wiki_create` (page) collision at the CLI layer. Run `taktcli <noun> --help` for a
group's verbs.

Every MCP tool maps to a verb:

| MCP tool | `taktcli` verb |
|----------|----------------|
| `get_task` | `get <id>` / `task get <id>` |
| `list_tasks` | `task list` |
| `search_tasks` | `task search <q>` |
| `get_board` | `board` |
| `create_task` | `task create` |
| `update_task` | `task update <id>` |
| `update_status` | `status <id> <STATUS>` (or `run` / `done` / `fail`) |
| `claim_task` | `claim <id>` |
| `release_claim` | `release <id>` |
| `force_release_claim` | `gql` (admin claim required) |
| `transition_task` | `task transition <id> <action>` |
| `get_dependencies` | `task deps <id>` |
| `add_comment` | `task comment <id>` |
| `get_task_comments` | `task comments <id>` |
| `attach_result` | `task result <id>` |
| `get_task_results` | `task results <id>` |
| `delete_result` | `task result-delete <id>` |
| `wiki_read` | `wiki read <path>` |
| `wiki_search` / `wiki_semantic_search` | `wiki search <q>` (`--semantic`) |
| `wiki_grep` | `wiki grep <pattern>` |
| `wiki_create` | `wiki create <path>` |
| `wiki_update` | `wiki update <path>` |
| `wiki_edit` | `wiki edit <path>` |
| `wiki_move` | `wiki move <path> <new-path>` |
| `wiki_verify` | `wiki verify <path>` |
| `wiki_delete` | `wiki delete <path>` |
| `wiki_bulk_update_status` | `wiki bulk-status <folder> <status>` |
| `find_related_wiki_pages` | `wiki related <task-id>` |
| *(none — CLI-only; MCP tool removed in takt-432)* | `wiki feedback list` |
| *(none — CLI-only; MCP tool removed in takt-432)* | `wiki feedback resolve <id>` |
| `wiki_list` | `wikis list` |
| `create_wiki` | `wikis create` |
| *(none — CLI-only; GraphQL `updateWiki`)* | `wikis update <slug>` |
| `get_project_wiki` | `wikis get <project>` |
| `request_upload_url_and_headers` + `confirm_upload` | `file upload <path>` (composite) |
| `get_file_url` | `file url <id>` |
| `list_files` | `file list` |
| `attach_file_to_task` | `file attach <id> <task-id>` |
| `detach_file_from_task` | `file detach <id> <task-id>` |
| `attach_file_to_wiki` | `file attach-wiki <id> <path>` |
| `update_label` / `delete_label` | `label update <name>` / `label delete <name>` |
| *(none — CLI-only)* | `label create` |
| `list_notifications` | `notif list` |
| `mark_notification_read` | `notif read <id>` |
| `mark_all_notifications_read` | `notif read-all` |
| `unread_notification_count` | `notif count` |
| *(none — CLI-only)* | `project {list,get,create,rename,member-add,member-remove}` |
| `taskStatusChanged` / `taskChanged` / `resultAdded` / `commentAdded` | `watch` (needs `[watch]` extra) |

Notes:

- `label create` is CLI-only (the MCP server never wrapped `createLabel`, but the
  GraphQL mutation exists). `label update` / `delete` are keyed by **name** and
  resolve the id client-side via the `labels` query.
- The `wiki` page verbs take `--wiki SLUG` (default `$TAKT_WIKI`)
  and stream big markdown through `--content-file` / `--old-file` / `--new-file`
  (`-` = stdin).
- The wiki-container verbs are `wikis list/create/get/update` (entity = **Wiki**). The
  old `workspace list/create/get` verbs are a hard error naming the replacement
  for one release — *workspace* is being reclaimed to mean the tenant, and
  nothing else.
- `project rename` is name-only — slug changes are blocked on the backend.

### Update a Wiki container

```bash
taktcli wikis update knowledge --name "Team knowledge"
taktcli wikis update knowledge --description "Shared documentation"
taktcli wikis update knowledge --description-file description.md
taktcli wikis update knowledge --description-file -
taktcli wikis update knowledge --new-slug team-docs --yes-rewrite-links
```

The current slug is required explicitly; this command does not use `$TAKT_WIKI`.
Provide at least one field. Omitted fields are preserved; `--description ""`
stores an empty string. `--description-file` reads UTF-8 text (`-` reads stdin)
and cannot be combined with `--description`. Name and description require
`write:wiki` within the token's workspace and wiki allowlist.

`--new-slug` requires `--yes-rewrite-links`; omitting confirmation exits `3`
before sending a request. Confirmation does not bypass the server's human OWNER
check. Actual slug changes currently return `NOT_IMPLEMENTED` with a clear server
message on stderr and exit `1`, pending mention reconciliation in takt-428.
Successful metadata updates print `{"updateWiki":{"slug":…, "name":…,
"description":…}}`; `--raw` and `--compact` follow the normal output contract.
Rewrite counts and unfixable links are not returned by the current server.

## Output & exit codes

Default output is pretty JSON of the unwrapped `.data` payload. `--compact` =
single-line; `--raw` = full `{data, errors, extensions}` envelope; `watch` =
NDJSON (one event per line, flushed per event).

| Code | Meaning |
|------|---------|
| `0` | success |
| `1` | GraphQL error (server reached; `errors[]` non-empty) |
| `2` | transport error (DNS/TCP/TLS/HTTP non-2xx/timeout/WS drop) |
| `3` | config or usage error (no token, bad URL, unknown verb, missing flag) |

`extensions.code` (e.g. `LEASE_EXPIRED`, `ALREADY_CLAIMED`) is preserved in error
output so loops can branch on it.

## Task monitoring — `taktcli watch`

Replaces the retired `tools/wait_for_task.py`. Streams subscription events as
NDJSON and reconnects with exponential backoff on drops (e.g. deploys). Needs the
`[watch]` extra.

```bash
taktcli watch --project takt --timeout 3600     # status changes, any task
taktcli watch --task takt-42 --task takt-43      # specific tasks
taktcli watch --project takt --event resultAdded # other subscriptions
```

`--event` defaults to `taskStatusChanged`. Unlike the old one-shot script,
`watch` is a continuous stream — read NDJSON lines as they arrive and act on the
first matching one; no restart-per-event. Tasks already READY when the watcher
starts are **not** detected — check `taktcli board --project takt` first.

## MCP coexistence

`taktcli` and the MCP server run **side-by-side over the same GraphQL API**,
authenticating identically (JWT, same claims). Use either or both in one session.
The MCP freeze/removal timeline lives in PRD 002 §8.

## Documentation

- **Usage reference:** `takt/context/taktcli` in the Takt wiki.
- **Design & rationale:** `takt/dev/prd-002-taktcli` (PRD 002).
- **Contract placement:** `takt/contract` §4.3 — the CLI as a first-class
  integration interface alongside GraphQL + MCP.

## Release

Push a `cli-v*` tag (e.g. `cli-v0.1.0`) — `.github/workflows/cli.yaml` builds
`cli/` and OIDC trusted-publishes to PyPI. The tag must match the version in
`cli/pyproject.toml` (CI asserts this). The CLI has its own CI job alongside
backend/plugin/frontend (`npm run test:cli`).
