Metadata-Version: 2.5
Name: luge-cli
Version: 0.45.0
Summary: Drive the Luge AI-employee platform from the command line — boards, agents, chat, workflows, and more, built for agents like Codex and Claude Code.
Project-URL: Homepage, https://github.com/Tchat-N-Sign/luge-cli
Project-URL: Repository, https://github.com/Tchat-N-Sign/luge-cli
Project-URL: Issues, https://github.com/Tchat-N-Sign/luge-cli/issues
Author-email: Sylvain Boily <sylvainboilydroid@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,ai,claude-code,cli,codex,kanban,luge
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development
Classifier: Topic :: Utilities
Requires-Python: >=3.12
Requires-Dist: httpx>=0.27
Requires-Dist: rich>=13.7
Requires-Dist: tomli-w>=1.0
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# luge-cli

[![CI](https://github.com/Tchat-N-Sign/luge-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Tchat-N-Sign/luge-cli/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/luge-cli.svg)](https://pypi.org/project/luge-cli/)
[![Python](https://img.shields.io/pypi/pyversions/luge-cli.svg)](https://pypi.org/project/luge-cli/)

A command-line client for the **Luge AI-employee platform** — built to let an
agent (Codex or Claude Code) drive Luge: work kanban boards, run scheduled tasks and
workflows, chat in channels / DMs / with AI agents, manage artifacts, personal
skills, and inbound webhooks, and read/update tenant settings.

Commands are grouped by noun: `board` and `card` (read + work a board),
`todo` (your personal todo list) and `plan` (compose a day plan),
`schedule` (recurring agent tasks), `workflow` + `activity` (run and inspect
agent workflows and their runs), `notification` (your notification-center
inbox), `channel` / `dm` / `agent` / `colleagues` (group
chats, direct messages, talking to an AI, and the people directory), `artifact`
(conversation deliverables), `skill` (authored Luge skills), `webhook`
(inbound webhook endpoints), `settings` (tenant config), `auth` (local
credentials), and `profile` (switching between saved backends). No card
deletion, no column management.

`luge-cli mcp` serves that same surface to an MCP client over stdio, with the
tools generated from the CLI itself — see [MCP server](#mcp-server).

## Install

From [PyPI](https://pypi.org/project/luge-cli/):

```bash
uv tool install luge-cli     # recommended — installs the `luge-cli` command globally
# or: pipx install luge-cli
# or: pip install luge-cli
```

Then install the bundled skill for the agent that will drive the CLI:

```bash
luge-cli codex skill install    # ${CODEX_HOME:-~/.codex}/skills/luge-platform
luge-cli claude skill install   # ~/.claude/skills/luge-platform
```

With `uv tool`, make sure uv's bin dir is on your `PATH` (once): `uv tool update-shell`.
Upgrade with `uv tool upgrade luge-cli` (or `pip install -U luge-cli`).

### From source (development)

```bash
cd luge-cli
make install        # editable uv tool install + the Codex and Claude Code skills
```

`--editable` means `git pull` updates the command with no reinstall. `make help`
lists every target.

## Configure

Needs the Luge API base URL and a tenant API key (`luge_…`). The base URL is the
full API base, including any deployment prefix (usually `/api`).

```bash
luge-cli auth init --url https://luge.example.com/api --token luge_xxx
luge-cli auth show    # verify (token is masked)
```

**Without a key to paste**, pair the machine in a browser instead:

```bash
luge-cli auth login --url https://luge.example.com/api
```

It asks the server for a pairing and prints a link plus a short code — approve it
in a browser you are already signed into (or enter the code from a phone) and the
key the server mints for that approval is written into the profile. The key is
scoped to a snapshot of your capabilities, shows up in `auth key list` under this
machine's name, and revoking it there ends this CLI's access. Nothing is written
if the pairing is refused or expires. A machine with no human at a browser —
CI, an unattended agent — still uses `auth init`.

`LUGE_URL` / `LUGE_TOKEN` environment variables override the stored file. The
config lives at `${XDG_CONFIG_HOME:-~/.config}/luge-cli/config.toml`, written
`0600` — never commit it.

### Profiles

Credentials are stored under named profiles, so a dev and a prod backend can
coexist and you switch instead of retyping:

```bash
luge-cli profile add dev  --url http://localhost:8000/api --token luge_dev_xxx
luge-cli profile add prod --url https://luge.example.com/api --token luge_prod_xxx

luge-cli profile list                     # all profiles, `*` marks the active one
luge-cli profile use prod                 # switch the persistent default
luge-cli --profile dev board list         # one-off override (flag goes before the subcommand)
LUGE_CLI_PROFILE=dev luge-cli board list  # per-shell/session override
```

Selection precedence: `--profile` > `LUGE_CLI_PROFILE` > `profile use` > the
sole stored profile. `LUGE_URL` / `LUGE_TOKEN` still override whatever profile
was selected, field by field. `auth init` stays the first-run bootstrap: it
writes whichever profile the run targets (`default` on a fresh machine). A
config file from before profiles existed keeps working — it reads as a profile
named `default` and is rewritten in the new shape on the next write.

### Output for agents and scripts

Every command that prints data takes `--json`. `LUGE_CLI_JSON=1` turns it on for
all of them at once, so an agent or a CI job sets it once instead of remembering
a flag on every call:

```bash
export LUGE_CLI_JSON=1
luge-cli table row list Expenses      # JSON, no flag
luge-cli table row list Expenses --json   # same, explicit
LUGE_CLI_JSON=0 luge-cli table list # human view, whatever the environment says
```

This matters more than it looks: the human views are grids that fit themselves
to the terminal and clip what does not fit — and outside a terminal that width
is 80 columns, so a piped `table row list` silently ellipsizes long cells. `--json` is
never truncated. If something reads the output rather than a person, give it
JSON.

(It is `LUGE_CLI_JSON`, not `LUGE_JSON`: `LUGE_*` is the platform's own
namespace — this only concerns the CLI's output.)

### Saying an AI wrote it

A second switch belongs in the same export block when an agent is driving:
`LUGE_CLI_AS_AGENT=1` marks every message it sends as **written by an AI**. The
message keeps the key holder's identity — same sender, same name — and the
conversation records who *composed* it, so a colleague reading a DM or a channel
sees an AI tag instead of assuming a person typed it.

```bash
export LUGE_CLI_AS_AGENT=1                        # a whole agent session
luge-cli dm send "Alex" "deployed, all green"     # marked AI-written
luge-cli dm send "Alex" "on my way" --as-human    # relaying Alex's colleague verbatim
luge-cli channel post general "build is red" --as-agent   # one message, no export
```

It is **declared, never inferred**: the platform does not read an API key as an
AI (a key is as likely to belong to a deploy script), so a message carries no
marker unless the sender asks for one. Nothing sent before this existed changes —
no marker reads as human, which is what it always was. Reading back, `dm show` /
`channel messages` tag those messages `[AI]`, and `--json` carries `author_kind`.

`luge-cli mcp` turns it on by itself: only a model calls a tool on that
transport. A host that drives the server from a script can say
`LUGE_CLI_AS_AGENT=0` and keep its own answer.

A second nicety is **on by default**: after any command that talked to the API,
one line on **stderr** flags unread notifications (`🔔 3 unread notification(s)
— luge-cli notification list`). In JSON mode — `--json` on the command or
`LUGE_CLI_JSON=1` — the bell matches the form:
`{"unread_notifications": 3, "hint": "luge-cli notification list --unread --json"}`.
The streams stay separate on purpose: stdout is the command's JSON document,
stderr is the bell's — each parses on its own; don't merge them with `2>&1`
before parsing. It rings only after successful,
networked commands, never for the `notification` group itself (you are looking
at the inbox), and any failure of the probe is silent — it can never break a
command or pollute piped/`--json` stdout. Acknowledging (`notification ack`) is
what stops the re-alert.

Turning it off, most specific wins: `--no-notify` (root flag, one run) >
`LUGE_CLI_NOTIFY=0` (shell/session) > `luge-cli notification disable`
(persisted in the config file; `notification enable` restores the default).

## Use

Commands are grouped by noun (`board`, `card`, `auth`, `skill`):

```bash
luge-cli board list                                  # boards you can access
luge-cli board show Roadmap                          # columns + cards (id or name substring)
luge-cli board show Roadmap --limit 50               # show more of each column
luge-cli board create Roadmap -d "Product roadmap"   # new board, seeded with default columns
luge-cli board create Feuille -l fr                  # seed the columns in French (À faire…)
luge-cli board update Roadmap --name "Q3 Roadmap"    # rename, describe, or set the card prefix
luge-cli board update Roadmap --prefix RDM           # only renumbers cards created from then on
luge-cli board history Roadmap                       # audit trail: the board's own facts + its cards'
luge-cli board archive Roadmap                       # freeze it read-only; the cards stay
luge-cli board list --archived                       # what is frozen (the default list hides it)
luge-cli board unarchive Roadmap                     # writable again, back in the active list
luge-cli board delete Roadmap                        # destroys the board and every card on it (asks first)
luge-cli card list Roadmap --status open --tag bug   # filter by tag/color/column/assignee/priority
luge-cli card show ROL-17                            # one card + comment thread + artifacts
luge-cli card history ROL-17                         # who did what to the card, newest first
luge-cli card mine                                   # cards assigned to you
luge-cli card search "login" --status open           # free-text across your boards

luge-cli card create Roadmap "In progress" "Fix login bug" --priority high --tag bug
luge-cli card create Roadmap "To do" "Sub-task" --parent ROL-17  # grouped at creation
luge-cli card comment ROL-17 "Picking this up"
luge-cli card move ROL-17 "In progress"
luge-cli card move-board ROL-17 "Roadmap"            # transfer to another board (card is renumbered)
luge-cli card set-parent ROL-18 ROL-17               # group ROL-18 under ROL-17 (same board)
luge-cli card remove-parent ROL-18                   # detach it again
luge-cli card reorder-children ROL-17 ROL-20 ROL-18  # set the sub-cards' manual order
luge-cli card done ROL-17                            # sets the completed flag (not the column)
luge-cli card reopen ROL-17

luge-cli card attach ROL-17 ./report.pdf             # upload a local file and link it to the card
luge-cli card attach ROL-17 --document loi25          # link a document already in the corpus (id or name)
luge-cli card detach ROL-17 3                        # detach attachment #3 (or its link id)
luge-cli card read ROL-17 1                          # read attachment #1's content

luge-cli <group> <command> --json                    # structured output for scripts/agents
```

A card is named by the `display_id` every listing prints (`ROL-17`) or its uuid —
its board is worked out for you, so you can paste back what you just read. Add
`--board` to name a card by a title substring instead (`card show "login" --board
Roadmap`), or to settle the rare case of two boards issuing the same id.

`card list` and `card create` still take a board first: they act on a board, not
on a card.

Boards, columns and (with `--board`) card titles are referenced by id **or** a
unique name substring; an ambiguous reference is a loud error, never a silent
guess.

A board is retired in one of two ways, both owner-or-manager only. `board
archive` freezes it read-only and takes it out of the listings, cards kept and
`board unarchive` undoing it. `board delete` is final: every card goes with the
board — its comments, tags and attachment links included. Because a board is
named by a substring, the board is read first so the prompt can name it and
count its cards before you answer; `--yes` skips that prompt for scripts.

An archived board is out of `board list`, `card mine` and `card search` until it
comes back — `board list --archived` is the view that shows it. `unarchive` and
`delete` resolve a name against the archived boards too; every other command
reads the active listing.

## Todos and day plans

`todo` is your personal todo list — the standalone kind, the same rows kanban
cards are made of but not placed on a board (cards stay in the `card` group;
`todo list --cards` is the one read that crosses over). `todo tag` curates the
tenant-wide tag vocabulary those cards and todos share.

```bash
luge-cli todo list --open --mine              # your open todos (--done, --cards, --limit)
luge-cli todo show "bank"                     # id or title substring
luge-cli todo create "Call the bank" --priority high --tag admin --due 2026-08-05
luge-cli todo update "bank" --notes "ask for Mr Roy" --due ""    # "" clears the due date
luge-cli todo done "bank" ; luge-cli todo reopen "bank"
luge-cli todo delete "bank"                   # (asks to confirm; -y to skip)
luge-cli todo tag list                        # the tenant vocabulary, most-used first
luge-cli todo tag create bug --color red      # idempotent on the name
luge-cli todo tag update bug --color sky      # recolours it on every card that links it
```

`plan` composes a **day plan** — the Planification surface. A plan holds
schedulable entries (todos and free `--note` blocks) plus materials (documents
and tables, which carry no time slot). Days read as `today` / `tomorrow` /
`yesterday` / `YYYY-MM-DD`; times read as `HH:MM` **local wall clock** on that
day (the CLI converts to a timezone-aware timestamp for the API — a full ISO
datetime also works).

```bash
luge-cli plan show                            # today's plan (or a day, or a shared plan's id)
luge-cli plan days --from today --to 2026-08-31   # the days that have a plan
luge-cli plan add --todo "bank" --start 09:00 --end 09:30    # schedule a todo today
luge-cli plan add tomorrow --note "Deep work" --notes "spec review" --start 13:00 --end 15:00
luge-cli plan add --todo ROL-17               # a kanban card is a todo too
luge-cli plan add --document loi25            # material: alongside the day, no slot
luge-cli plan update <entry-id> --start 14:00 --day tomorrow  # reschedule (HH:MM needs its day)
luge-cli plan update <entry-id> --unschedule  # clear the slot, keep the entry
luge-cli plan remove <entry-id>               # the todo/document itself is untouched
```

The first `plan add` on a day creates its plan; re-adding the same resource
returns the existing entry instead of duplicating it. `plan show` prints each
entry's id — that is the handle `update` / `remove` take.

The **daily-planning automation** materializes a personal copy of the planning
skill and a daily scheduled task that composes your day (LUG-303 in the app):

```bash
luge-cli plan automation status
luge-cli plan automation enable --time 07:30 --mode propose   # propose = you validate each plan
luge-cli plan automation enable --mode direct                 # write the plan without asking
luge-cli plan automation disable                              # task kept, never deleted
```

## Tables

A table is a small typed data store — the "Tables" surface in the app. `table`
manages the table and its columns; `table row` reads and writes the data. The
rows sit *under* the table rather than beside it because a row has no id you can
paste on its own — the API addresses one as `/datasets/{id}/rows/{row_id}`, so
every row command names a table. (A card earns a group of its own by naming
itself: `card show ROL-17`. A row never can.)

```bash
luge-cli table list                                  # tables you can see
luge-cli table show Expenses                         # column spec + row count
luge-cli table create Expenses \
        --column 'label:text!' \
        --column 'amount:number|Amount (CAD)' \
        --column 'status:select!=open,paid' \
        --column 'due:date' \
        --column 'who:user=$user'
luge-cli table update Expenses --name Costs -d "Team costs"
luge-cli table delete Expenses                       # rows included (-y to skip the prompt)

luge-cli table row list Expenses                           # every row, paginated
luge-cli table row list Expenses -w 'status=open' -w 'amount>20' --sort amount --desc
luge-cli table row show Expenses 48b3e75d                  # one row in full, a cell per line
luge-cli table row create Expenses -s 'label=Taxi' -s 'amount=42.50' -s 'status=open'
luge-cli table row update Expenses 48b3e75d -s 'status=paid'   # short id, as printed
luge-cli table row update Expenses 48b3e75d --clear due        # empty an optional cell
luge-cli table row delete Expenses 48b3e75d

# Relational columns — pass the whole spec as JSON (link / lookup / rollup)
luge-cli table create Affaires --columns-json '[
  {"key":"titre","label":"Titre","type":"text","required":true},
  {"key":"entreprise","label":"Entreprise","type":"link","target_dataset_id":"<uuid>","multiple":false},
  {"key":"dom","label":"Domaine","type":"lookup","via":"entreprise","target_field":"domaine"}
]'
luge-cli table row create Affaires -s 'titre=Renouvellement' --link-by 'entreprise=nom:Acme'
luge-cli table row create Affaires -s 'titre=Onboarding' -s 'entreprise=["<row-uuid>"]'
```

A **column** is `key:type`, plus `!` to make it required, `=a,b,c` for a
select's options, `=$variable` to have Luge fill it in, and `|Label` to name it
(the label defaults to a readable form of the key — `unit_price` → "Unit
price"). Types: `text`, `number`, `date`, `boolean`, `select`, `user`.

| spec | means |
| --- | --- |
| `title:text!` | required text |
| `amount:number\|Amount (CAD)` | optional number, explicit label |
| `status:select!=open,paid` | required select (the `!` rides on the type) |
| `who:user=$user` | filled with whoever inserts the row |
| `logged:date=$datetime` | filled with the insert time |

Variable columns (`$user`, `$display_name`, `$datetime`, `$email`) are filled in
by Luge, so `table row create` never asks for them.

**Relational columns** — `link`, `lookup`, `rollup` — carry three or four fields
each, more than the compact grammar holds, so pass the whole spec as JSON with
`--columns-json '[…]'` or `--columns-file spec.json` (the array the API validates,
sent through untouched — so a column type added later works with no CLI change).
The three column sources are mutually exclusive; each JSON form is the whole spec.

| type | JSON fields (beyond `key` / `label` / `type`) |
| --- | --- |
| `link` | `target_dataset_id` (uuid), `multiple` (bool, default true) |
| `lookup` | `via` (a link column's key), `target_field` (a key on the linked table) |
| `rollup` | `via`, `agg` ∈ `count\|sum\|avg\|min\|max\|concat`, `target_field` (unless `agg=count`) |

A **link cell** is a list of target row ids. Write it with a bare id
(`-s 'entreprise=<uuid>'`) or a JSON array (`-s 'entreprise=["<uuid>","<uuid>"]'`),
or name the target by a business value with `--link-by 'entreprise=nom:Acme'` —
Luge matches that against the linked table (`field == value`) and errors if
nothing matches. `--link-by` is one per link column; for several explicit targets
use `-s 'entreprise=["<uuid>","<uuid>"]'`. `lookup` and `rollup` cells are
computed by Luge, so `--set` never writes them. `table row list` / `show` print a
link cell by the linked row's name (from the response's `link_labels`), and
`table show` summarizes each relational column's wiring.

Cells are typed by that spec: `--set amount=42` sends the number `42`, and a
value the column cannot hold is refused before the request goes out. `--where`
takes `key=value`, `key!=value`, `key~text` (contains), and `key>n` / `key>=n` /
`key<n` / `key<=n` on numbers and dates; filters AND together. Quote any filter
using `<` or `>` so the shell does not read it as a redirection.

`table row list` is a grid, so it fits its cells to the terminal; `table row
show` reads one row whole, a cell per line, without reaching for `--json`. A row is named by the
short id the grid prints (a unique prefix is enough) and never by its position
in a listing — a rank depends on the filters that produced it.

`table update --column` replaces the **whole** spec — pass every column you
keep. A kept key keeps its type, and dropping a column hides its data (it
returns if you re-add the key), so the CLI names what would be dropped and asks
first. `table show` prints the current spec.

Reads cover your own tables plus tenant-visible and shared ones; writes act on
tables you own. Sharing is managed in the app.

## Scheduled tasks

A scheduled task is a recurring prompt that fires an agent run. Manage them under
`luge-cli schedule`:

```bash
luge-cli schedule list [--enabled]                         # tasks you can see
luge-cli schedule show "Daily digest"                       # one task (id or name)
luge-cli schedule create "Daily digest" --prompt "Summarise open cards" \
        --every daily --at 09:00                            # see --every below
luge-cli schedule update "Daily digest" --every weekly:mon,fri --at 08:30
luge-cli schedule toggle "Daily digest"                     # pause / resume
luge-cli schedule run "Daily digest"                        # trigger a one-off run now
luge-cli schedule delete "Daily digest"                     # (asks to confirm; -y to skip)
```

`--every` is a compact recurrence spec (the API uses structured recurrence, not
cron): `daily`, `hourly`, `weekly:mon,wed,fri`, `monthly:15`, `interval:4h`,
`interval:2d`, `once:2026-08-01T18:00`. `--at HH:MM` sets the time(s) of day
(repeatable; defaults to `09:00`). A run's history/result isn't on the task — a
triggered run surfaces under `luge-cli activity` (see below).

## Workflows and activity

`luge-cli workflow` inspects, authors and runs multi-step agent workflows:

```bash
luge-cli workflow list [--trigger-type event] [--enabled]   # workflow definitions
luge-cli workflow show "veille-techno"                        # one definition (id or name)
luge-cli workflow create "Greeter" --agent "General Agent" --prompt "Say hi and the time"
luge-cli workflow create "Complex" --graph ./graph.json --trigger-type event
luge-cli workflow update "Complex" --graph ./graph.json      # only passed fields change
luge-cli workflow update "Relances" --enable                 # or --disable, --name, ...
luge-cli workflow delete "Greeter"                            # (asks to confirm; -y to skip)
luge-cli workflow trigger "veille-techno" --data topic=rust   # start a run (repeatable --data)
luge-cli workflow runs "veille-techno"                        # a workflow's run history
luge-cli workflow run "veille-techno" <run-id>                # one run in full
luge-cli workflow cancel "veille-techno" <run-id>             # cancel a running run
luge-cli workflow retry  "veille-techno" <run-id>             # retry a failed/cancelled run
```

`create --agent <id|name> --prompt "..."` builds a linear single-agent workflow
(start → agent → end) for you; `create --graph <file.json>` posts an arbitrary
roomkit-graph you supply (for branches, human-review, notifications, etc.).
`update` patches only the fields you pass; `--graph` replaces the graph as a
whole — `workflow show <ref> --json`, edit the `graph` object, pass it back.

`luge-cli workflow node` addresses a graph **node by node**, so changing one
node's config no longer means round-tripping the whole graph:

```bash
luge-cli workflow node list "test table"                       # id, type, config keys of each node
luge-cli workflow node show "test table" function-1784         # one node whole (id or unique prefix)
luge-cli workflow node update "test table" function-1784 --set code=@snippet.js
luge-cli workflow node update "test table" function-1784 --set timeout_ms=5000  # typed by the node schema
```

`node update` patches only the keys you `--set` (repeatable): it fetches the
graph, merges the change into the one node's `config`, and PUTs the graph back
(the server validates it as a whole — there is no node endpoint). Each `--set`
is typed against the node type's config schema the way a row's cells are typed
against its column spec, so `timeout_ms=5000` leaves as a number, not `"5000"`.
Use `key=@file` to read a value from a file — the way to carry a `function`
node's multi-line `code`; an undeclared key travels as a plain string.

An **event** trigger fires the workflow off another surface. Pass `--trigger-type
event` with a `--trigger-config` JSON that names the `source_type` (`workflow`,
`scheduled_task`, `webhook`, `board`, or `data_table`) and, optionally, filters —
e.g. fire on a new/updated row in a Luge table:

```bash
luge-cli workflow create "On new order" --agent "Ops" --prompt "Handle it" \
  --trigger-type event \
  --trigger-config '{"source_type":"data_table","source_data_table_id":"<table id>","events":["row_created"]}'
```

`luge-cli activity` is the cross-surface run inbox — every run (schedule,
workflow, webhook, notetaker, …), for diagnosing what fired and what failed:

```bash
luge-cli activity list [--status failed] [--kind schedule] [--search "..."]
luge-cli activity show <id>       # a run's message thread + human-review steps
luge-cli activity retry <id>      # re-run a failed/cancelled run
luge-cli activity stats [--days 7]
```

`show` / `retry` take either id a run carries: the `id` from `activity list` (its
chat room) or the `execution_id` — the one `schedule run` prints — so you can
paste back whichever id you were given. Workflow runs have no chat room; inspect
those with `workflow runs` instead.

## Notifications

`luge-cli notification` reads your **notification-center inbox** — mentions,
card assignments, run failures, meeting summaries, membership changes. The API
key sees the inbox of the user who owns it.

```bash
luge-cli notification list                 # newest first; ● marks unread
luge-cli notification list --unread --json # what an agent should consume
luge-cli notification ack <id>             # mark one read (id from `list`)
luge-cli notification ack --all            # clear the whole inbox
```

The `--unread` filter applies within the fetched page (the API has no
server-side unread filter) — raise `--limit` (max 100) or page with `--offset`
to see more. The verb is `ack`, not `read`: `card read` already means *display
content*, and acknowledging is a write. `enable` / `disable` persist the
after-command bell switch (see above). The agent loop: bell on stderr → `list
--unread --json` → handle → `ack` — acknowledging is what stops the re-alert.

## Settings

`luge-cli settings` reads and updates tenant configuration. **Most of it needs an
admin-role API key** — a member key gets 403 on the memory/pii/compliance/
web-search slices and on credentials; `agents`, `providers` and the `tenant` read
are member-safe.

```bash
luge-cli settings get memory                      # memory | pii | compliance | web-search | tenant
luge-cli settings set web-search web_search_provider=brave   # partial patch (only keys you pass)
luge-cli settings set memory rag_max_chunks=8 summarization_enabled=true
luge-cli settings agents                          # the tenant's agents (provider/model live here)
luge-cli settings agent "General Agent"           # one agent's config (id or name)
luge-cli settings providers                       # AI provider/model catalogue
```

`set` values are typed: `true`/`false` → bool, numbers → int/float, `json:[...]`
or `json:{...}` for lists/objects, anything else a string.

### Credentials

Where a provider is *pointed at* — an ollama's `base_url`, an Anthropic
`api_key` — and where an integration's token lives. One field at a time
(`credential.manage`, i.e. an admin key's `integrations` scope).

```bash
luge-cli settings credential list [--type ollama]   # which fields are set; values never returned
luge-cli settings credential set ollama base_url http://localhost:11434
luge-cli settings credential set anthropic api_key -    # read the secret from stdin
luge-cli settings credential set anthropic api_key      # or be prompted, without echo
luge-cli settings credential delete ollama base_url --yes
luge-cli settings credential delete ollama --yes        # every field of the type
```

Prefer `-` or the prompt over typing a secret as an argument: an argument lands
in the shell history and the process list. A secret is write-only — the API
returns the value of descriptive fields (`base_url`, `model`) and `null` for
anything secret, so a key can be replaced but never read back.

Nothing pings the provider: the value is stored as sent, and the first agent turn
is what proves it. Deleting the last field an agent's provider needs takes that
agent off the air at its next turn — these are read live, nothing is restarted
for the breakage to happen.

## Conversations: channels, DMs, agents

Sending a message has three distinct surfaces — one command group each — plus a
`colleagues` directory to find who to DM.

**Group chat** — `luge-cli channel` participates in **team channels**. Reads and
posts are plain; the AI answers only when a message `@luge`-mentions it.

```bash
luge-cli channel list ; luge-cli channel browse       # channels you can see / can join
luge-cli channel show general                          # one channel (id or name)
luge-cli channel messages general --limit 50           # message history (oldest-first)
luge-cli channel post general "ship it @luge"          # @luge to invoke the agent
luge-cli channel post general "build is red" --as-agent   # an AI wrote it, not you
luge-cli channel join general ; luge-cli channel leave general
luge-cli channel create team --display "Team" --public --agent <id>
```

**Direct message** — `luge-cli dm` sends a 1:1 message to a colleague (human↔human,
never triggers the AI). `dm send` opens the DM (get-or-create) and posts in one go.

```bash
luge-cli dm send "Alex" "got a sec?"     # colleague by id, email, or name
luge-cli dm send "Alex" "deployed" --as-agent   # an AI wrote it on your behalf
luge-cli dm list                          # your DMs
luge-cli dm show "Alex" --limit 50        # read the thread
```

`--as-agent` on either send (or `LUGE_CLI_AS_AGENT=1` for a whole session) keeps
your identity on the message and marks it AI-written — see
[Saying an AI wrote it](#saying-an-ai-wrote-it).

**Agent chat** — `luge-cli agent` talks to an AI agent.

```bash
luge-cli agent send "Summarise the open cards" [--agent <id|name>] [--conversation <id>]
luge-cli agent send "Any blockers?" --wait --json   # wait for the reply → {conversation_id, reply}
luge-cli agent list                                  # your agent conversations
luge-cli agent show <id> --limit 50                  # a conversation + its messages
```

By default `agent send` is **fire-and-forget**: it invokes an agent and returns the
conversation id immediately (read the reply later with `agent show <id>`). With
`--wait` it polls until the agent's text reply arrives and prints it (intermediate
tool-call steps are skipped; tune with `--timeout` / `--interval`) — the
agent-friendly "ask → answer" form, especially with `--json`. `--agent` accepts an
id or a name.

**Why an agent answered that way** — `luge-cli conversation context <id>` prints
what the model actually received on its **last** turn: the verbatim system
prompt, the tool schemas, the skills manifest, the injected blocks (workspace
graph, document context, retrieved memories, running summary) and the final
message list. It is the command-line twin of the app's chat debug panel.

```bash
luge-cli conversation context <id>                  # the shape of the turn + the skill states
luge-cli conversation context <id> --show prompt    # the system prompt, verbatim
luge-cli conversation context <id> --show skills    # catalogue / proposed / activated
luge-cli conversation context <id> --show blocks    # every injected block, in injection order
luge-cli conversation context <id> --json           # the whole snapshot, lossless
```

**Only on a recent conversation**: the snapshot is written per agent turn into
Redis with a one-hour TTL and overwritten by the next turn. Past that you get
"no snapshot" plus the DB-backed injection counts, which survive.

`--show skills` is the one that earns the command. A skill can be *in the
catalogue*, *proposed* to the model (a `<skill_recommendation>` block, which sits
inside the messages) or *activated* by it — and "the skill didn't fire" covers
two opposite causes, triggers that never matched and a description the model
ignored, with opposite fixes. Needs `token_usage.view` (an admin key's
`compliance` scope) plus access to the conversation itself.

**React** — `luge-cli message` acts on a single message by its id (the chat "Copy
ID" action). Emoji reactions work the same in a channel or a DM:

```bash
luge-cli message react <messageId> 👍       # add a reaction (safe to repeat)
luge-cli message unreact <messageId> 👍     # remove yours
luge-cli message reactions <messageId>      # who reacted, with what
```

**Read an attached file** — `message attachment` opens what a message carries, in
a channel or a DM alike. Text prints on stdout; anything binary reports its type
and size rather than dumping bytes, and `-o` writes it out (a directory keeps the
original filename). The download is streamed, so a meeting recording never lands
in memory.

```bash
luge-cli message attachment list <messageId>              # number, filename, type, size
luge-cli message attachment get <messageId>               # the first file's text
luge-cli message attachment get <messageId> 2 -o .        # write it here, original name
```

**Colleagues** — `luge-cli colleagues` is the platform people directory.

```bash
luge-cli colleagues list [--type human]   # the roster
luge-cli colleagues get "Alex"            # a colleague's profile (id, role, skills)
```

## Artifacts

`luge-cli artifact` works with conversation artifacts (markdown/html/code
deliverables). Every artifact lives in a room — the `--room` id is a
conversation/channel id (shown by `chat show` / `channel messages`).

```bash
luge-cli artifact mine                              # your artifacts (summaries)
luge-cli artifact list --room <room-id>             # a room's artifacts (with content)
luge-cli artifact show <id>                          # one artifact + its content
luge-cli artifact create --room <room-id> "Notes" ./notes.md --type markdown
echo "# Draft" | luge-cli artifact create --room <room-id> "Draft" -    # from stdin
luge-cli artifact delete <id>                        # (asks to confirm; -y to skip)
```

There is no update-in-place — `create` always inserts a new artifact.

## Semantic search

`luge-cli search` runs the platform's unified semantic (vector) search across
notes and documents — it finds meaning, not substrings (`note search` stays the
lexical counterpart). Each hit shows its similarity, its workspace-graph
`node_id`, and a `connected:` line naming what links to it in the graph
(depth-1 neighbourhood).

```bash
luge-cli search "how do we handle auth tokens"       # notes + documents, by meaning
luge-cli search "auth" --type note                   # one type only (repeatable)
luge-cli search "auth" --limit 5 --min-similarity 0.3   # cast wider than the 0.5 default
luge-cli search "auth" --json                        # raw server response (diagnostics)
```

Documents join the results only when your token carries document read access —
a restricted API key simply sees fewer types. The search needs a default
embeddings agent configured on the platform; without one the server's 400 is
reported as-is.

## Documents — the RAG corpus

`luge-cli document` manages the tenant's document corpus: the knowledge layer
agents retrieve from. A document is a file (or a pointer to one); `rag` decides
whether it is chunked and embedded for retrieval, and folders are a single flat
level it can be filed under.

```bash
luge-cli document list --status ready                # the corpus (filter by folder/status)
luge-cli document show "loi25"                       # type, size, RAG, folder — and why it failed
luge-cli document add ./prd.pdf --scope tenant --rag # upload and index it
luge-cli document rag "loi25" --off                  # stop indexing it (the file stays)
luge-cli document move "loi25" Specs                 # file it under a folder
luge-cli document folder create Specs                # folders are one flat level
```

`document reference` records a file that lives on Google Drive or SharePoint
without copying it — the provider stays the source of truth and the corpus
stores a pointer the agent resolves on demand:

```bash
luge-cli document reference "Q3 deck" --source microsoft --external-id 01ABC \
    --file-type pptx --drive-id 'b!…' --url https://sharepoint.example/x --rag
```

Reading what the corpus holds:

```bash
luge-cli document chunks "loi25"                     # the pieces it was split into
luge-cli document chunks "loi25" --indexes 0,3       # specific chunk indexes
luge-cli document download "loi25"                   # text prints; binary reports type + size
luge-cli document download "loi25" -o ./out.pdf      # streamed; --force to overwrite
```

### Embedding coherence and re-indexing

Switching the tenant's embeddings agent to a model of a different vector width
invalidates every vector already stored, and nothing announces it: the writes
keep going, and search silently drops the rows of the old width. `document
embeddings` is how you find out.

```bash
luge-cli document embeddings                  # the coherence report + its verdict
luge-cli document reindex                     # the cure — asks first (--yes for scripts)
luge-cli document reprocess "loi25"           # the same, for one document
```

The report probes the model live — one network round-trip to Ollama/OpenAI, so
it is not a free read — and prints the effective vector width against the width
pinned on the agent, the stored widths per corpus (`document_chunks`,
`note_chunks`, `memories`) with their stale-row counts, any ANN index standing
on an embedding column, and whether a re-index is required. An unreachable
provider degrades the report instead of failing it: the widths and the indexes
that need dropping still print.

`document reindex` drops any ANN index and re-embeds all three corpora, so it
confirms before acting. It queues the work and returns — what it prints is what
was accepted, not what has finished; `document embeddings` reads the outcome
back. These four commands need `document.manage`; a token without it gets a
plain refusal.

## Sharing — who can access a resource

`luge-cli share` is one generic surface over every ownable object: a board, a
todo, a day plan, a document or its folder, a table, a workflow, a scheduled
task, a webhook, a channel, a meeting, a notetaker capture, a note. The reference is the
same id-or-name used everywhere else — a plan, which has no name, is referenced
by its **day** (`share grant plan today Alice`). Managing a resource's shares is
owner-only (a channel also lets a channel manager do it).

```bash
luge-cli share show board Roadmap                    # visibility + who it is shared with
luge-cli share readers document "PRD loi25"          # the concrete users who can read it
luge-cli share grant board Roadmap Alice             # read access for a colleague
luge-cli share grant table Budget Alice --write      # write (editor) instead of read
luge-cli share grant workflow Nightly --tenant       # visible to the whole tenant
luge-cli share revoke board Roadmap Alice            # take it back (--team / --tenant too)
luge-cli share request document <id>                 # ask the owner for read access
```

`request` is the other direction — the verb for a resource you *cannot* read. It
files an approval task in the owner's inbox; approving shares the resource with
you and you hear about it as a notification. Pass the **id**: a resource you
cannot read is in no listing, so no name resolves it. For a document, that id
comes from the conflict `document add` / `card attach` raises when the corpus
already holds those bytes — the one place an unreadable document's id surfaces.

The API answers a request the same way whether one was filed, you could already
read the resource, or a request was already pending. The CLI reports that limit
instead of claiming a new ask each time.

## Skills (personal & local)

`luge-cli skill` manages **authored Luge skills** — instruction sets (name,
description, instructions body, required tools, trigger phrases) that agents can
use. By default commands target your **personal** skills (visible only to you);
`--scope local` targets the tenant's shared local skills. Reading works with any
key; create/update/delete need the skill-manage capability (an admin/owner key).

```bash
luge-cli skill list                        # your personal skills, with created/updated
luge-cli skill list --scope local          # the tenant's shared local skills
luge-cli skill show <name>                  # description, instructions, requires, triggers, dates
luge-cli skill create my-skill --description "..." --instructions "..." \
        --require terminal_run --trigger "do the thing"
luge-cli skill create my-skill --instructions-file ./SKILL.md   # body from a file
luge-cli skill update my-skill --description "..." [--instructions … --require … --trigger …]
luge-cli skill update my-skill --instructions "…" --if-match "<updated_at from show>"
luge-cli skill delete my-skill              # (asks to confirm; -y to skip)
```

`--if-match` makes the update conditional: pass the `updated_at` you read (from
`show` or `list`) and the API answers **412** instead of overwriting a version
someone else changed since — re-read and retry. Without it, last write wins.

(Not to be confused with `luge-cli codex skill` / `luge-cli claude skill`, which
install *this CLI's own* agent skill — see [Agent skill](#agent-skill) below.)

## Inbound webhooks

`luge-cli webhook` manages inbound webhook endpoints — receivers Luge hosts so an
external system can drive automation. Each endpoint POSTs arriving at its
`inbound_url` are routed to an **agent**, a **workflow**, or a **data table** (or
just logged). The CLI creates/inspects endpoints and reads their deliveries;
Luge's server receives the webhooks.

```bash
luge-cli webhook list
luge-cli webhook create "GitHub CI" --to agent --target "General Agent" --source github
        # prints the inbound_url + signing secret (secret is shown ONCE — save it)
luge-cli webhook create "Stripe" --to workflow --target "veille-techno" --source stripe
luge-cli webhook create "Orders" --to data_table --target "Orders" \
        --map customer=data.customer.name --map amount=data.total   # one row per POST
luge-cli webhook create "Debug" --to log_only          # just log, triggers nothing
luge-cli webhook show <id|name>
luge-cli webhook deliveries <id|name>                   # what has arrived (the journal)
luge-cli webhook delivery <id|name> <delivery-id>       # one delivery in full
luge-cli webhook update <id|name> --disable | --regenerate-secret
luge-cli webhook delete <id|name>
```

`--to agent|workflow` resolves `--target` (an agent or workflow, by id or name)
into the endpoint's destination. `--to data_table` resolves `--target` to a table
and maps the payload into a row: each `--map <column>=<payload.path>` fills a
column from a dot-path into the delivery body, and every required column must be
mapped. The signing `secret` is returned only on create and `--regenerate-secret`
— capture it then, it is not shown again.

## Agent skill

The package bundles an agent skill (`luge-platform`) for Codex and Claude Code. It teaches the
whole CLI — the card-work protocol (read the thread, announce, deliver, mark done
only when asked) plus the schedule / workflow / activity / channel / dm / agent /
artifact / webhook / settings surfaces and their non-obvious semantics. `make
install` installs it; to (re)install it on its own:

```bash
luge-cli codex skill install --force    # copies it to ${CODEX_HOME:-~/.codex}/skills/luge-platform
luge-cli claude skill install --force   # copies it to ~/.claude/skills/luge-platform
```

Unlike the editable CLI, the skill is a **copy** — it does not update on
`git pull`. Re-run the matching `skill install --force` command after the bundled
skill changes.

## MCP server

For a host that speaks [MCP](https://modelcontextprotocol.io) but has no shell,
`luge-cli mcp` serves the whole CLI over stdio:

```jsonc
// Claude Desktop / Claude Code / any MCP client
{
  "mcpServers": {
    "luge": { "command": "luge-cli", "args": ["mcp"] }
  }
}
```

The tools are **generated from the CLI itself** at startup — the Click command
tree is walked and turned into one tool per command group, with each group's
verbs as an enum and every option and argument as a typed field. Nothing is
declared by hand, so a command added to the CLI is a tool the server offers on
its next start, and two tests (`tests/test_mcp.py`) fail the build if anything
in the tree ever becomes unreachable.

Call a tool by naming the verb in `command` and its arguments beside it:

```jsonc
{ "name": "luge_card",
  "arguments": { "command": "create", "board": "Roadmap",
                 "column": "To do", "title": "Ship the MCP server" } }
```

Results are this CLI's `--json` output verbatim — it is passed automatically, so
nothing comes back truncated. `profile` picks the connection per call, and
credentials come from the usual config (`luge-cli auth show`).

Narrow the surface when a client does not need all of it:

```bash
luge-cli mcp --read-only                  # reads only — no create/update/delete/send
luge-cli mcp --group card,note,board      # these groups (repeatable, or comma-separated)
luge-cli mcp --exclude 'auth *' --exclude '* delete'
luge-cli mcp --list-tools                 # print the generated definitions, do not serve
```

`--read-only` classifies by verb and treats anything it does not recognise as a
write, so a newly added command stays out until someone says otherwise. A message
sent through the server is marked AI-written by default — nothing but a model
calls a tool over that transport — unless the host sets `LUGE_CLI_AS_AGENT=0`.

## Develop

```bash
make sync            # create/refresh the dev environment
make hooks           # install the pre-commit git hooks (once)
make check           # lint (ruff) + type-check (mypy) + test (pytest) — what CI runs
make format          # auto-fix lint issues and format
```

`make test` runs the suite (filters, resolution, HTTP client mocked, config, CLI
wiring). CI (GitHub Actions) runs lint, type-check, and test on every push and PR.

### Release

```bash
make build           # sdist + wheel (runs `check` first)
make publish         # upload to PyPI via twine + ~/.pypirc  (REPO=testpypi to test)
make release         # tag vX.Y.Z + create a GitHub release from CHANGELOG.md
```

Bump `version` in `pyproject.toml` and add a `CHANGELOG.md` entry before releasing.
