takibibase 1.0.0
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.
- package/LICENSE +10 -0
- package/README.md +51 -0
- package/SKILL.md +75 -0
- package/package.json +30 -0
- package/takibi.mjs +723 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Copyright (c) 2026, Takibi (also known as Takibi Base, takibibase.com).
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
This software is proprietary to Takibi. You may install and run it
|
|
5
|
+
solely as a client for Takibi services. You may not copy, modify,
|
|
6
|
+
redistribute, or sublicense it, except as expressly permitted by
|
|
7
|
+
Takibi in writing.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
10
|
+
EXPRESS OR IMPLIED.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# takibi agent kit: CLI + skill
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
npx takibibase … # zero-install: always the latest release
|
|
5
|
+
npm i -g takibibase # or install once, then run `takibi …`
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
Needs Node.js 22+. This directory is also the npm package source:
|
|
9
|
+
`takibi.mjs` stays a single zero-dependency file so the
|
|
10
|
+
`/downloads/takibi.mjs` copy and the published package never drift.
|
|
11
|
+
|
|
12
|
+
Thin client-side enablement for agents. Zero changes to `apps/api`,
|
|
13
|
+
`apps/web`, schema, or grants — this directory is the whole feature.
|
|
14
|
+
|
|
15
|
+
- `takibi.mjs` — the CLI. Single-file Node ≥ 22, zero dependencies.
|
|
16
|
+
Run it as `node tools/takibi/takibi.mjs …`, or alias it:
|
|
17
|
+
`alias takibi='node <checkout>/tools/takibi/takibi.mjs'`.
|
|
18
|
+
- `SKILL.md` — the `takibi-use` skill. Short on purpose; the CLI carries
|
|
19
|
+
the contract. Install it into your harness's skills dir, or paste it
|
|
20
|
+
into an agent's first prompt for a zero-install test.
|
|
21
|
+
|
|
22
|
+
## Account-owner setup (once per machine)
|
|
23
|
+
|
|
24
|
+
1. Mint a key: in the app, Settings → Profiles → new profile with the
|
|
25
|
+
capabilities the crew needs (`search`, `ask`, `tasks`), scoped to its
|
|
26
|
+
project(s). Copy the `<publicId>.<secret>` shown once.
|
|
27
|
+
2. `mkdir -p ~/.takibi && printf '%s\n' '<publicId>.<secret>' > ~/.takibi/key && chmod 600 ~/.takibi/key`
|
|
28
|
+
3. Optional: one base-URL line in `~/.takibi/config` (default
|
|
29
|
+
`http://localhost:3849`). Env overrides: `$TAKIBI_KEY_FILE`,
|
|
30
|
+
`$TAKIBI_BASE_URL`.
|
|
31
|
+
4. `takibi projects --add <name> <uuid>` per project (UUIDs are in
|
|
32
|
+
Settings → Projects). `GET /v1/projects` is account-only, so names
|
|
33
|
+
live in `~/.takibi/projects` as `name=uuid` lines; UUIDs also work
|
|
34
|
+
directly in `--project`, and single-grant keys may omit it.
|
|
35
|
+
5. `takibi version` — probes the API without needing the key.
|
|
36
|
+
|
|
37
|
+
## Give an agent
|
|
38
|
+
|
|
39
|
+
Key file in place + the skill text (or installed skill). Acceptance: a
|
|
40
|
+
fresh agent asks, searches, and lists tasks against a local boot on the
|
|
41
|
+
first try, with no contract pasted in chat. Reads are free; task writes
|
|
42
|
+
need your approval in conversation; account-only routes stay yours.
|
|
43
|
+
|
|
44
|
+
## Known edges
|
|
45
|
+
|
|
46
|
+
- `tasks get` filters the board list client-side (no single-task GET).
|
|
47
|
+
- `doc download`/`delete`/`retry`/`upload` refuse client-side with a
|
|
48
|
+
pointer — Bearer keys can never reach account-only routes (the server
|
|
49
|
+
answers those 401, not 403).
|
|
50
|
+
- The CLI never prints the key: not in output, errors, `--verbose`
|
|
51
|
+
(method + URL + status only), or exit traces (output is scrubbed).
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: takibi-use
|
|
3
|
+
description: Use the Takibi knowledge base and task board through the takibi CLI (ask, search, tasks, docs). Use whenever the user asks about Takibi content, evidence-backed answers from their docs, or crew task boards.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# takibi-use: Takibi via the CLI
|
|
7
|
+
|
|
8
|
+
Always use the CLI. Never hand-roll curl/fetch against `/v1/*` — the CLI
|
|
9
|
+
already bakes in the auth shape, the `q` param, project resolution, and
|
|
10
|
+
error hints.
|
|
11
|
+
|
|
12
|
+
## Setup (once)
|
|
13
|
+
|
|
14
|
+
- CLI: `npx takibibase …` (zero-install), `takibi …` (global install),
|
|
15
|
+
or `node tools/takibi/takibi.mjs …` from the takibi-base checkout
|
|
16
|
+
(use the alias when it exists).
|
|
17
|
+
- Key: the founder saves one `<publicId>.<secret>` line to `~/.takibi/key`
|
|
18
|
+
(`chmod 600`). The key is never printed, never pasted in chat, never
|
|
19
|
+
committed. If a command says the key is missing, stop and ask.
|
|
20
|
+
- Base URL defaults to `http://localhost:3849` (local boot). Override with
|
|
21
|
+
`$TAKIBI_BASE_URL` or one URL line in `~/.takibi/config`.
|
|
22
|
+
- Projects: `takibi projects` lists saved names. `--project` takes a name
|
|
23
|
+
or a UUID; single-grant keys may omit it. New name? Ask the founder for
|
|
24
|
+
the UUID once, then `takibi projects --add <name> <uuid>`.
|
|
25
|
+
- First probe: `takibi version` (needs no key; shows build + `jev` status).
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
- `takibi ask -q "…"` — answer from the evidence. Output is verbatim spans
|
|
30
|
+
with citations plus a support line. `--project`, `--folder <uuid>`,
|
|
31
|
+
`-k 1-12`, `--json`.
|
|
32
|
+
- `takibi search -q "…"` — ranked chunks (snippets + metadata, not full
|
|
33
|
+
text). Same flags, `-k 1-20`.
|
|
34
|
+
- `takibi tasks list | get <id> | claim <id> | status <id> <todo|in_progress|review|done>`
|
|
35
|
+
and `takibi tasks artifact add <taskId> <url> [--note …]`.
|
|
36
|
+
- `takibi doc list | get <id> | text <id>` — metadata, then converted text.
|
|
37
|
+
`doc download` is founder-only; the CLI says so — use `doc text`.
|
|
38
|
+
- `--json` anywhere prints raw server JSON. `--verbose` logs requests
|
|
39
|
+
(never the key). Exit 0 = ok, 1 = transport/API error, 2 = usage error.
|
|
40
|
+
|
|
41
|
+
## Reading answers
|
|
42
|
+
|
|
43
|
+
- Spans are verbatim with citations (`doc <uuid>`, `chunk <uuid>`) and a
|
|
44
|
+
versioned support score. Support is not confidence — report the number,
|
|
45
|
+
never upgrade it into certainty.
|
|
46
|
+
- Honor `answerability` (`answerable|partial|unanswerable|unknown`) and
|
|
47
|
+
`conflict`. On `conflict: yes`, surface both sides; never smooth it over.
|
|
48
|
+
- Jev-down is not app-down: `/ask` still answers with degraded fields
|
|
49
|
+
(`answerability: unknown`, `conflict: false`) and search is unaffected.
|
|
50
|
+
|
|
51
|
+
## Abstains (exit 0, not an error)
|
|
52
|
+
|
|
53
|
+
`abstained: true` (`No answer in the evidence.`) means: broaden `q`, fall
|
|
54
|
+
back to `search`, try `doc text` on the hits — then either answer from
|
|
55
|
+
evidence or say the evidence is not there. Never fill gaps with generated
|
|
56
|
+
prose presented as sourced.
|
|
57
|
+
|
|
58
|
+
## Mutation policy
|
|
59
|
+
|
|
60
|
+
- Reads are free: ask, search, tasks list/get, doc list/get/text.
|
|
61
|
+
- Task claim/status/artifact writes only with founder approval already
|
|
62
|
+
given in conversation. Claim-first: a plain key must claim a card before
|
|
63
|
+
moving or touching it; only orchestrators/founders accept (`review→done`),
|
|
64
|
+
assign others, or archive.
|
|
65
|
+
- Founder-only routes (upload, delete, retry, download originals, PATCH
|
|
66
|
+
docs/projects) are never the agent's to call — ask the founder.
|
|
67
|
+
|
|
68
|
+
## Failure table
|
|
69
|
+
|
|
70
|
+
- 401: key wrong/missing/revoked/disabled — or a founder-only route
|
|
71
|
+
(the CLI names it). 403: outside the grant or orchestrator-only.
|
|
72
|
+
404: bad id (the server hides grant gaps as 404 too).
|
|
73
|
+
- 409 on claim: someone already holds the card — the message names them.
|
|
74
|
+
- 429: minute throttle (slow down) or daily budget spent (resets tomorrow).
|
|
75
|
+
- Cannot-reach errors: the API is not booted; `takibi version` probes it.
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "takibibase",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Thin CLI for the Takibi API: ask, search, tasks, docs. Single file, zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"takibi": "./takibi.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"takibi.mjs",
|
|
11
|
+
"SKILL.md",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/tofuchick3n/takibi-base.git",
|
|
21
|
+
"directory": "tools/takibi"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://takibibase.com/docs/agents",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"takibi",
|
|
26
|
+
"cli",
|
|
27
|
+
"agents",
|
|
28
|
+
"knowledge-base"
|
|
29
|
+
]
|
|
30
|
+
}
|
package/takibi.mjs
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* takibi — thin CLI for the Takibi API. Single file, zero dependencies.
|
|
4
|
+
*
|
|
5
|
+
* The contract it bakes in (verified against apps/api/src, 2026-09-20):
|
|
6
|
+
* - Auth: `Authorization: Bearer <publicId>.<secret>`. The key comes from
|
|
7
|
+
* disk only ($TAKIBI_KEY_FILE or ~/.takibi/key) and is NEVER printed —
|
|
8
|
+
* not in output, errors, --verbose, or exit traces (see scrub()).
|
|
9
|
+
* - Origin: never sent. Absent Origin on a Bearer call is correct.
|
|
10
|
+
* - Ask/search take `q`, never `question`.
|
|
11
|
+
* - Base URL: $TAKIBI_BASE_URL or ~/.takibi/config, else localhost:3849.
|
|
12
|
+
*
|
|
13
|
+
* Pure-client deviations the server forces (no apps/api changes allowed):
|
|
14
|
+
* - GET /v1/projects is account-only, so `projects` reads a LOCAL map
|
|
15
|
+
* (~/.takibi/projects, `name=uuid` lines) and --project resolves names
|
|
16
|
+
* through it. UUIDs pass straight through.
|
|
17
|
+
* - There is no GET /v1/tasks/:id, so `tasks get` lists and filters.
|
|
18
|
+
* - Account-only routes (doc download, uploads, deletes) answer 401, not
|
|
19
|
+
* 403, to Bearer callers; `doc download` refuses client-side with a hint.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 ok (honest abstains included), 1 transport/API error,
|
|
22
|
+
* 2 usage error.
|
|
23
|
+
*/
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** Baked fallback; the published package re-reads package.json next door. */
|
|
29
|
+
const BAKED_VERSION = '1.0.0';
|
|
30
|
+
const CLI_VERSION = (() => {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
33
|
+
if (typeof pkg.version === 'string' && pkg.version.length > 0) return pkg.version;
|
|
34
|
+
} catch {
|
|
35
|
+
// Single-file download: no package.json travels with takibi.mjs.
|
|
36
|
+
}
|
|
37
|
+
return BAKED_VERSION;
|
|
38
|
+
})();
|
|
39
|
+
const DEFAULT_BASE_URL = 'http://localhost:3849';
|
|
40
|
+
const REQUEST_TIMEOUT_MS = 90_000;
|
|
41
|
+
const USER_AGENT = `takibibase/${CLI_VERSION}`;
|
|
42
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
43
|
+
/** Mirrors the server's verifyBearer split: <publicId>.<secret>. */
|
|
44
|
+
const KEY_RE = /^[A-Za-z0-9_.-]+\.[A-Za-z0-9_-]+$/;
|
|
45
|
+
const TASK_STATUSES = ['todo', 'in_progress', 'review', 'done'];
|
|
46
|
+
|
|
47
|
+
/** The live key, for scrubbing only — never interpolated into output. */
|
|
48
|
+
let activeKey = null;
|
|
49
|
+
const scrub = (s) => (activeKey && s.includes(activeKey) ? s.split(activeKey).join('<redacted>') : s);
|
|
50
|
+
|
|
51
|
+
/** Sync writes: answers (stdout) and metadata (stderr) stay in order when piped. */
|
|
52
|
+
const out = (s) => writeSync(1, `${s}\n`);
|
|
53
|
+
const err = (s) => writeSync(2, `${scrub(s)}\n`);
|
|
54
|
+
|
|
55
|
+
class CliError extends Error {
|
|
56
|
+
constructor(message, { hint = null, status = null, code = null, exitCode = 1 } = {}) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.hint = hint;
|
|
59
|
+
this.status = status;
|
|
60
|
+
this.code = code;
|
|
61
|
+
this.exitCode = exitCode;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const usageError = (message) => new CliError(message, { exitCode: 2 });
|
|
65
|
+
|
|
66
|
+
const takibiDir = () => join(homedir(), '.takibi');
|
|
67
|
+
|
|
68
|
+
/** First non-blank, non-comment line of a file, or null when missing. */
|
|
69
|
+
function firstLine(path) {
|
|
70
|
+
if (!existsSync(path)) return null;
|
|
71
|
+
const line = readFileSync(path, 'utf8')
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((l) => l.trim())
|
|
74
|
+
.find((l) => l && !l.startsWith('#'));
|
|
75
|
+
return line ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveBaseUrl() {
|
|
79
|
+
const raw = (process.env.TAKIBI_BASE_URL ?? '').trim() || firstLine(join(takibiDir(), 'config')) || DEFAULT_BASE_URL;
|
|
80
|
+
const base = raw.replace(/\/+$/, '');
|
|
81
|
+
if (!/^https?:\/\/.+/.test(base)) {
|
|
82
|
+
throw new CliError(`Base URL does not look like an http(s) URL: ${base}`, {
|
|
83
|
+
hint: 'Set $TAKIBI_BASE_URL or write one URL line to ~/.takibi/config.',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return base;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveKey() {
|
|
90
|
+
const path = (process.env.TAKIBI_KEY_FILE ?? '').trim() || join(takibiDir(), 'key');
|
|
91
|
+
if (!existsSync(path)) {
|
|
92
|
+
throw new CliError(`No API key file at ${path}.`, {
|
|
93
|
+
hint: 'Ask the account owner for a key, save its one <publicId>.<secret> line there (chmod 600), and retry.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const key = firstLine(path);
|
|
97
|
+
if (!key || !KEY_RE.test(key)) {
|
|
98
|
+
throw new CliError(`Key file ${path} does not hold a <publicId>.<secret> line.`, {
|
|
99
|
+
hint: 'The file holds one line: the publicId, a dot, then the secret. Nothing else.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
activeKey = key;
|
|
103
|
+
return key;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Local name→uuid map. Returns {map, warnings}; missing file is empty, not an error. */
|
|
107
|
+
function loadProjectMap() {
|
|
108
|
+
const path = join(takibiDir(), 'projects');
|
|
109
|
+
const map = new Map();
|
|
110
|
+
const warnings = [];
|
|
111
|
+
if (!existsSync(path)) return { map, warnings, path };
|
|
112
|
+
readFileSync(path, 'utf8')
|
|
113
|
+
.split('\n')
|
|
114
|
+
.forEach((raw, i) => {
|
|
115
|
+
const line = raw.trim();
|
|
116
|
+
if (!line || line.startsWith('#')) return;
|
|
117
|
+
const eq = line.indexOf('=');
|
|
118
|
+
const name = eq === -1 ? '' : line.slice(0, eq).trim();
|
|
119
|
+
const id = eq === -1 ? '' : line.slice(eq + 1).trim();
|
|
120
|
+
if (!name || !UUID_RE.test(id)) {
|
|
121
|
+
warnings.push(`${path}:${i + 1}: skipping malformed line (want name=uuid).`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!map.has(name)) map.set(name, id);
|
|
125
|
+
});
|
|
126
|
+
return { map, warnings, path };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** --project value → uuid-or-undefined. Names resolve via the local map. */
|
|
130
|
+
function resolveProjectFlag(value) {
|
|
131
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
132
|
+
const v = String(value).trim();
|
|
133
|
+
if (UUID_RE.test(v)) return v;
|
|
134
|
+
const { map } = loadProjectMap();
|
|
135
|
+
const hit = map.get(v);
|
|
136
|
+
if (hit) return hit;
|
|
137
|
+
const known = [...map.keys()];
|
|
138
|
+
throw new CliError(`Unknown project ${JSON.stringify(v)}.`, {
|
|
139
|
+
hint:
|
|
140
|
+
known.length > 0
|
|
141
|
+
? `Known names: ${known.join(', ')}. Pass a project UUID, or ask the account owner for this one.`
|
|
142
|
+
: 'Pass a project UUID, or ask the account owner to add names via `takibi projects --add <name> <uuid>`.',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hintFor(status, serverMessage) {
|
|
147
|
+
const msg = serverMessage || '';
|
|
148
|
+
if (status === 400 && /spans projects/i.test(msg)) return 'This key spans projects — pass --project <name-or-uuid>.';
|
|
149
|
+
if (status === 401) {
|
|
150
|
+
if (/login required/i.test(msg)) return 'That route is account-only. Ask the account owner; never the agent.';
|
|
151
|
+
if (/no longer valid/i.test(msg)) return 'That key is revoked or expired. Ask the account owner to mint a fresh one.';
|
|
152
|
+
if (/disabled/i.test(msg)) return 'That profile is disabled. Ask the account owner to enable it.';
|
|
153
|
+
return 'The key is missing or wrong. Check ~/.takibi/key (one <publicId>.<secret> line).';
|
|
154
|
+
}
|
|
155
|
+
if (status === 403) return 'Outside this key’s grant, or an owner/orchestrator-only move. Check `tasks get`, or ask the account owner.';
|
|
156
|
+
if (status === 404) return 'Bad id, or outside this key’s grant (the server hides the difference).';
|
|
157
|
+
if (status === 409) return null; // claim conflicts name the winner already.
|
|
158
|
+
if (status === 429) {
|
|
159
|
+
if (/too many calls/i.test(msg)) return 'Minute throttle (60/min/key). Slow down and retry in a bit.';
|
|
160
|
+
return 'Daily budget spent. Budgets reset tomorrow; ask the account owner for a bigger one if this blocks work.';
|
|
161
|
+
}
|
|
162
|
+
if (status !== null && status >= 500) return 'Server side. Retry in a moment; if it persists the owner checks API logs.';
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One API call. Verbose logs method + URL + status only — headers (which
|
|
168
|
+
* carry the key) are never logged, printed, or interpolated anywhere.
|
|
169
|
+
*/
|
|
170
|
+
async function api(method, path, { query = null, body = null, key = null, baseUrl, verbose = false, label = null } = {}) {
|
|
171
|
+
const qs = query ? `?${query.toString()}` : '';
|
|
172
|
+
const url = `${baseUrl}${path}${qs}`;
|
|
173
|
+
const headers = { accept: 'application/json', 'user-agent': USER_AGENT };
|
|
174
|
+
if (key) headers.authorization = `Bearer ${key}`;
|
|
175
|
+
if (body !== null) headers['content-type'] = 'application/json';
|
|
176
|
+
if (verbose) err(`→ ${method} ${url}`);
|
|
177
|
+
const started = Date.now();
|
|
178
|
+
let res;
|
|
179
|
+
try {
|
|
180
|
+
res = await fetch(url, {
|
|
181
|
+
method,
|
|
182
|
+
headers,
|
|
183
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
184
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
185
|
+
});
|
|
186
|
+
} catch (e) {
|
|
187
|
+
const why = e?.name === 'TimeoutError' ? `timed out after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s` : (e?.message ?? e);
|
|
188
|
+
throw new CliError(`Cannot reach the API at ${baseUrl} (${why}).`, {
|
|
189
|
+
hint: 'Is the API booted? `takibi version` probes it without needing a key.',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (verbose) err(`← ${res.status} in ${Date.now() - started}ms`);
|
|
193
|
+
let data = null;
|
|
194
|
+
try {
|
|
195
|
+
data = await res.json();
|
|
196
|
+
} catch {
|
|
197
|
+
throw new CliError(`Unexpected response from ${label ?? path} (HTTP ${res.status}, not JSON).`, {
|
|
198
|
+
status: res.status,
|
|
199
|
+
hint: `Is ${baseUrl} the Takibi API? \`takibi version\` says what is serving there.`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const message = data?.error?.message || `Request failed (HTTP ${res.status}).`;
|
|
204
|
+
throw new CliError(`${label ?? path}: ${message}`, {
|
|
205
|
+
status: res.status,
|
|
206
|
+
code: data?.error?.code ?? null,
|
|
207
|
+
hint: hintFor(res.status, message),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return data;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const qparams = (entries) => {
|
|
214
|
+
const p = new URLSearchParams();
|
|
215
|
+
for (const [k, v] of entries) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
|
|
216
|
+
return p.toString() ? p : null;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const HELP = `takibi ${CLI_VERSION} — thin CLI for the Takibi API.
|
|
220
|
+
|
|
221
|
+
Usage: takibi [global options] <command> [args]
|
|
222
|
+
|
|
223
|
+
Commands:
|
|
224
|
+
ask -q "..." Answer from the evidence (spans + citations + support)
|
|
225
|
+
search -q "..." Ranked chunks (snippets + metadata, no full text)
|
|
226
|
+
tasks list Board cards for the project
|
|
227
|
+
tasks get <id> One card with body + artifacts
|
|
228
|
+
tasks claim <id> Claim an open to-do card (atomic, one winner)
|
|
229
|
+
tasks status <id> <st> Move a card: todo | in_progress | review | done
|
|
230
|
+
tasks artifact add <taskId> <url> Attach a link-only artifact
|
|
231
|
+
doc list Documents (metadata)
|
|
232
|
+
doc get <id> One document's metadata
|
|
233
|
+
doc text <id> Converted text of one document
|
|
234
|
+
projects Saved project names (-> uuids for --project)
|
|
235
|
+
version What build is serving + jev wired|unwired (no key needed)
|
|
236
|
+
|
|
237
|
+
Global options (accepted before or after the command):
|
|
238
|
+
--project <name-or-uuid> Project scope. Names resolve via ~/.takibi/projects;
|
|
239
|
+
single-grant keys may omit it entirely.
|
|
240
|
+
--folder <uuid> Folder scope (ask, search, doc list only).
|
|
241
|
+
--json Raw server JSON instead of human-readable output.
|
|
242
|
+
--verbose Log method + URL + status to stderr (never the key).
|
|
243
|
+
-h, --help This help. --version prints the CLI version.
|
|
244
|
+
|
|
245
|
+
Per-command options:
|
|
246
|
+
ask/search: -q, --query <text> (or a bare positional), -k <n> (ask 1-12, search 1-20)
|
|
247
|
+
tasks list: --all (include archived)
|
|
248
|
+
tasks artifact add: --note <text> --size <bytes> --hash <hex>
|
|
249
|
+
doc list: --limit <n> (1-100) doc text: --max-chars <n>
|
|
250
|
+
projects: --add <name> <uuid>
|
|
251
|
+
|
|
252
|
+
Setup: save the owner-provided key (one <publicId>.<secret> line) to
|
|
253
|
+
~/.takibi/key (chmod 600), optional base URL line to ~/.takibi/config,
|
|
254
|
+
project names via \`takibi projects --add\`. Env overrides: $TAKIBI_KEY_FILE,
|
|
255
|
+
$TAKIBI_BASE_URL. Then \`takibi version\` to probe the API.
|
|
256
|
+
|
|
257
|
+
Reads are free. Task claim/status/artifact writes need owner approval in
|
|
258
|
+
conversation. Account-only routes (upload, delete, retry, download originals,
|
|
259
|
+
PATCH docs/projects) are never the agent's to call — ask the account owner.
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Pull globals out of argv wherever they sit; the rest stays positional for
|
|
264
|
+
* the command parser. Unknown --options fail loud (exit 2), so a typo like
|
|
265
|
+
* --question never silently becomes something else.
|
|
266
|
+
*/
|
|
267
|
+
function parseArgv(argv) {
|
|
268
|
+
const globals = { project: undefined, folder: undefined, json: false, verbose: false, help: false, version: false };
|
|
269
|
+
const rest = [];
|
|
270
|
+
const takeValue = (flag, i) => {
|
|
271
|
+
const v = argv[i + 1];
|
|
272
|
+
if (v === undefined || v === '--') throw usageError(`${flag} needs a value.`);
|
|
273
|
+
return [v, i + 1];
|
|
274
|
+
};
|
|
275
|
+
for (let i = 0; i < argv.length; i++) {
|
|
276
|
+
const a = argv[i];
|
|
277
|
+
if (a === '--') {
|
|
278
|
+
rest.push(...argv.slice(i + 1));
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
if (a === '--json') globals.json = true;
|
|
282
|
+
else if (a === '--verbose' || a === '-v') globals.verbose = true;
|
|
283
|
+
else if (a === '--help' || a === '-h') globals.help = true;
|
|
284
|
+
else if (a === '--version') globals.version = true;
|
|
285
|
+
else if (a === '--project' || a === '-p') {
|
|
286
|
+
const [v, ni] = takeValue(a, i);
|
|
287
|
+
globals.project = v;
|
|
288
|
+
i = ni;
|
|
289
|
+
} else if (a.startsWith('--project=')) globals.project = a.slice('--project='.length);
|
|
290
|
+
else if (a === '--folder' || a === '-f') {
|
|
291
|
+
const [v, ni] = takeValue(a, i);
|
|
292
|
+
globals.folder = v;
|
|
293
|
+
i = ni;
|
|
294
|
+
}
|
|
295
|
+
else if (a.startsWith('--folder=')) globals.folder = a.slice('--folder='.length);
|
|
296
|
+
else if (a === '--query' || a === '-q' || a === '--q' || a === '-k' || a === '--limit' || a === '--max-chars' || a === '--all' || a === '--add' || a === '--note' || a === '--size' || a === '--hash') {
|
|
297
|
+
rest.push(a);
|
|
298
|
+
if (a !== '--all') {
|
|
299
|
+
const [v, ni] = takeValue(a, i);
|
|
300
|
+
rest.push(v);
|
|
301
|
+
i = ni;
|
|
302
|
+
if (a === '--add') {
|
|
303
|
+
const [v2, ni2] = takeValue(a, i);
|
|
304
|
+
rest.push(v2);
|
|
305
|
+
i = ni2;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} else if (a.startsWith('-') && a.length > 1) {
|
|
309
|
+
throw usageError(`Unknown option ${JSON.stringify(a)}. See \`takibi --help\`.`);
|
|
310
|
+
} else {
|
|
311
|
+
rest.push(a);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { globals, rest };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Parse `-q/--query <text>`, `-k <n>` plus one optional positional query. */
|
|
318
|
+
function parseQueryArgs(tokens, { kMin, kMax }) {
|
|
319
|
+
let q = null;
|
|
320
|
+
let k = null;
|
|
321
|
+
const positionals = [];
|
|
322
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
323
|
+
const t = tokens[i];
|
|
324
|
+
if (t === '-q' || t === '--query' || t === '--q') q = tokens[++i];
|
|
325
|
+
else if (t === '-k') k = tokens[++i];
|
|
326
|
+
else positionals.push(t);
|
|
327
|
+
}
|
|
328
|
+
if (q === null || q === undefined || q === '') {
|
|
329
|
+
if (positionals.length === 0) throw usageError('Pass the query: -q "..." (tip: the param is q, never question).');
|
|
330
|
+
q = positionals.shift();
|
|
331
|
+
}
|
|
332
|
+
if (positionals.length > 0) throw usageError(`Unexpected ${JSON.stringify(positionals[0])}. Pass the query once.`);
|
|
333
|
+
if (!String(q).trim()) throw usageError('The query needs at least one non-space character.');
|
|
334
|
+
if (k !== null && k !== undefined) {
|
|
335
|
+
const n = Number(k);
|
|
336
|
+
if (!Number.isInteger(n) || n < kMin || n > kMax) throw usageError(`-k takes an integer ${kMin}-${kMax}.`);
|
|
337
|
+
k = n;
|
|
338
|
+
}
|
|
339
|
+
return { q: String(q), k };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const fmtSupport = (a) =>
|
|
343
|
+
`support ${Number(a.support ?? 0).toFixed(2)} (${a.supportVersion ?? 'v?'}) · answerability: ${a.answerability ?? 'unknown'} · conflict: ${a.conflict ? 'yes — flag it, do not smooth it over' : 'no'} · candidates: ${a.candidateCount ?? 0}`;
|
|
344
|
+
|
|
345
|
+
function renderAsk(a, q) {
|
|
346
|
+
if (a.abstained || !a.spans?.length) {
|
|
347
|
+
out('No answer in the evidence.');
|
|
348
|
+
err(`(${fmtSupport(a)} — broaden q, fall back to \`takibi search\`, and say so honestly.)`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
a.spans.forEach((s, i) => {
|
|
352
|
+
out(`[${i + 1}] ${s.text}`);
|
|
353
|
+
out(` ${s.documentName} — ${s.title}`);
|
|
354
|
+
out(` doc ${s.documentId} · chunk ${s.chunkId}`);
|
|
355
|
+
});
|
|
356
|
+
err(fmtSupport(a));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function renderSearch(results, q) {
|
|
360
|
+
if (!results.length) {
|
|
361
|
+
out(`No results for ${JSON.stringify(q)}.`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
results.forEach((r, i) => {
|
|
365
|
+
out(`[${i + 1}] ${r.snippet}`);
|
|
366
|
+
out(` ${r.documentName} — ${r.title} · rank ${r.rank} · doc ${r.documentId} · chunk ${r.chunkId}`);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const taskLine = (t) => {
|
|
371
|
+
const bits = [t.id, `· ${t.status}`, `· ${t.title}`, `· ${t.assigneeName ?? 'unassigned'}`];
|
|
372
|
+
if (t.blocked) bits.push(`· blocked: ${t.blockedReason || 'no reason given'}`);
|
|
373
|
+
if (t.artifacts?.length) bits.push(`· ${t.artifacts.length} artifact${t.artifacts.length === 1 ? '' : 's'}`);
|
|
374
|
+
if (t.archivedAt) bits.push('· archived');
|
|
375
|
+
if (t.dueAt) bits.push(`· due ${t.dueAt}`);
|
|
376
|
+
return bits.join(' ');
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
function renderTask(t) {
|
|
380
|
+
out(taskLine(t));
|
|
381
|
+
if (t.body) out(`\n${t.body}`);
|
|
382
|
+
if (t.artifacts?.length) {
|
|
383
|
+
out('');
|
|
384
|
+
for (const a of t.artifacts) out(` - ${a.url}${a.note ? ` — ${a.note}` : ''} [${a.id}]`);
|
|
385
|
+
}
|
|
386
|
+
err(`created ${t.createdAt} · updated ${t.updatedAt}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const fmtBytes = (n) => {
|
|
390
|
+
if (n === null || n === undefined) return '?';
|
|
391
|
+
if (n < 1024) return `${n} B`;
|
|
392
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
393
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const docLine = (d) => `${d.id} · ${d.name} · ${d.mime} · ${fmtBytes(d.bytes)} · ${d.status}${d.stale ? ' · stale' : ''}`;
|
|
397
|
+
|
|
398
|
+
function renderDoc(d) {
|
|
399
|
+
out(docLine(d));
|
|
400
|
+
out(` folder: ${d.folderId ?? '(unfiled)'} · chars: ${d.charCount ?? '?'} · confidence: ${d.confidence ?? '?'}`);
|
|
401
|
+
out(` ttl: ${d.ttlDays}d · created: ${d.createdAt}`);
|
|
402
|
+
if (d.supersedesId) out(` supersedes: ${d.supersedesId}`);
|
|
403
|
+
if (d.quarantineReason) out(` quarantined: ${d.quarantineReason} — ${d.quarantineAction ?? ''}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Shared context: baseUrl always; key for every command except version/help. */
|
|
407
|
+
function ctxFor(globals, { needKey }) {
|
|
408
|
+
const baseUrl = resolveBaseUrl();
|
|
409
|
+
const key = needKey ? resolveKey() : null;
|
|
410
|
+
const projectId = resolveProjectFlag(globals.project);
|
|
411
|
+
const { warnings } = loadProjectMap();
|
|
412
|
+
for (const w of warnings) err(`warning: ${w}`);
|
|
413
|
+
if (globals.folder !== undefined && globals.folder !== null && globals.folder !== '' && !UUID_RE.test(globals.folder)) {
|
|
414
|
+
throw usageError(`--folder takes a folder UUID, not ${JSON.stringify(globals.folder)}. (Folder names are not resolvable — keys cannot list folders.)`);
|
|
415
|
+
}
|
|
416
|
+
return { baseUrl, key, projectId, folder: globals.folder || undefined, json: globals.json, verbose: globals.verbose };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function cmdAsk(tokens, globals) {
|
|
420
|
+
const { q, k } = parseQueryArgs(tokens, { kMin: 1, kMax: 12 });
|
|
421
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
422
|
+
// GET carries the contract; POST only when q outgrows a sane URL.
|
|
423
|
+
const data =
|
|
424
|
+
q.length > 1500
|
|
425
|
+
? await api('POST', '/v1/ask', {
|
|
426
|
+
...ctx,
|
|
427
|
+
body: { q, projectId: ctx.projectId, folderId: ctx.folder, k: k ?? undefined },
|
|
428
|
+
label: 'ask',
|
|
429
|
+
})
|
|
430
|
+
: await api('GET', '/v1/ask', {
|
|
431
|
+
...ctx,
|
|
432
|
+
query: qparams([
|
|
433
|
+
['q', q],
|
|
434
|
+
['projectId', ctx.projectId],
|
|
435
|
+
['folderId', ctx.folder],
|
|
436
|
+
['k', k],
|
|
437
|
+
]),
|
|
438
|
+
label: 'ask',
|
|
439
|
+
});
|
|
440
|
+
if (ctx.json) out(JSON.stringify(data, null, 2));
|
|
441
|
+
else renderAsk(data, q);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function cmdSearch(tokens, globals) {
|
|
445
|
+
const { q, k } = parseQueryArgs(tokens, { kMin: 1, kMax: 20 });
|
|
446
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
447
|
+
const data = await api('GET', '/v1/search', {
|
|
448
|
+
...ctx,
|
|
449
|
+
query: qparams([
|
|
450
|
+
['q', q],
|
|
451
|
+
['projectId', ctx.projectId],
|
|
452
|
+
['folderId', ctx.folder],
|
|
453
|
+
['k', k],
|
|
454
|
+
]),
|
|
455
|
+
label: 'search',
|
|
456
|
+
});
|
|
457
|
+
if (ctx.json) out(JSON.stringify(data, null, 2));
|
|
458
|
+
else renderSearch(data.results ?? [], q);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function listTasks(ctx, includeArchived) {
|
|
462
|
+
const data = await api('GET', '/v1/tasks', {
|
|
463
|
+
...ctx,
|
|
464
|
+
query: qparams([
|
|
465
|
+
['projectId', ctx.projectId],
|
|
466
|
+
['includeArchived', includeArchived ? '1' : undefined],
|
|
467
|
+
]),
|
|
468
|
+
label: 'tasks list',
|
|
469
|
+
});
|
|
470
|
+
return data.tasks ?? [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function cmdTasks(tokens, globals) {
|
|
474
|
+
if (globals.folder) throw usageError('--folder does not apply to tasks: cards are project-wide, folder grants stay a KB-read concept.');
|
|
475
|
+
const [sub, ...rest] = tokens;
|
|
476
|
+
if (!sub || sub === 'list') {
|
|
477
|
+
let includeArchived = false;
|
|
478
|
+
for (const t of rest) {
|
|
479
|
+
if (t === '--all') includeArchived = true;
|
|
480
|
+
else throw usageError(`Unexpected ${JSON.stringify(t)}. Usage: takibi tasks list [--all]`);
|
|
481
|
+
}
|
|
482
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
483
|
+
const tasks = await listTasks(ctx, includeArchived);
|
|
484
|
+
if (ctx.json) {
|
|
485
|
+
out(JSON.stringify({ tasks }, null, 2));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (!tasks.length) out(includeArchived ? 'No tasks (archived included).' : 'No tasks.');
|
|
489
|
+
for (const t of tasks) out(taskLine(t));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (sub === 'get') {
|
|
493
|
+
const [id, extra] = rest;
|
|
494
|
+
if (!id || extra) throw usageError('Usage: takibi tasks get <id>');
|
|
495
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
496
|
+
let tasks = await listTasks(ctx, false);
|
|
497
|
+
let found = tasks.find((t) => t.id === id);
|
|
498
|
+
if (!found) {
|
|
499
|
+
tasks = await listTasks(ctx, true);
|
|
500
|
+
found = tasks.find((t) => t.id === id);
|
|
501
|
+
}
|
|
502
|
+
if (!found) throw new CliError(`tasks get: No such task in this project (${id}).`, { status: 404, hint: hintFor(404, '') });
|
|
503
|
+
if (ctx.json) out(JSON.stringify({ task: found }, null, 2));
|
|
504
|
+
else renderTask(found);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (sub === 'claim') {
|
|
508
|
+
const [id, extra] = rest;
|
|
509
|
+
if (!id || extra) throw usageError('Usage: takibi tasks claim <id>');
|
|
510
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
511
|
+
const data = await api('POST', `/v1/tasks/${id}/claim`, { ...ctx, query: qparams([['projectId', ctx.projectId]]), label: 'tasks claim' });
|
|
512
|
+
if (ctx.json) out(JSON.stringify(data, null, 2));
|
|
513
|
+
else renderTask(data.task);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (sub === 'status') {
|
|
517
|
+
const [id, to, extra] = rest;
|
|
518
|
+
if (!id || !to || extra) throw usageError('Usage: takibi tasks status <id> <todo|in_progress|review|done>');
|
|
519
|
+
if (!TASK_STATUSES.includes(to)) throw usageError(`Status is one of ${TASK_STATUSES.join(' | ')}, not ${JSON.stringify(to)}.`);
|
|
520
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
521
|
+
const data = await api('PATCH', `/v1/tasks/${id}`, {
|
|
522
|
+
...ctx,
|
|
523
|
+
query: qparams([['projectId', ctx.projectId]]),
|
|
524
|
+
body: { status: to },
|
|
525
|
+
label: 'tasks status',
|
|
526
|
+
});
|
|
527
|
+
if (ctx.json) out(JSON.stringify(data, null, 2));
|
|
528
|
+
else renderTask(data.task);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (sub === 'artifact' || sub === 'artifacts') {
|
|
532
|
+
const [verb, taskId, url, ...flagTokens] = rest;
|
|
533
|
+
if (verb !== 'add' || !taskId || !url) throw usageError('Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]');
|
|
534
|
+
const flags = {};
|
|
535
|
+
for (let i = 0; i < flagTokens.length; i++) {
|
|
536
|
+
const f = flagTokens[i];
|
|
537
|
+
if (f === '--note' || f === '--size' || f === '--hash') flags[f.slice(2)] = flagTokens[++i];
|
|
538
|
+
else throw usageError(`Unexpected ${JSON.stringify(f)}. Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]`);
|
|
539
|
+
}
|
|
540
|
+
if (flags.note !== undefined && flags.note === '') throw usageError('--note needs non-empty text.');
|
|
541
|
+
if (flags.size !== undefined) {
|
|
542
|
+
const n = Number(flags.size);
|
|
543
|
+
if (!Number.isInteger(n) || n < 0) throw usageError('--size takes an integer >= 0 (asserted bytes).');
|
|
544
|
+
flags.size = n;
|
|
545
|
+
}
|
|
546
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
547
|
+
const data = await api('POST', `/v1/tasks/${taskId}/artifacts`, {
|
|
548
|
+
...ctx,
|
|
549
|
+
query: qparams([['projectId', ctx.projectId]]),
|
|
550
|
+
body: { url, note: flags.note, assertedSize: flags.size, assertedHash: flags.hash },
|
|
551
|
+
label: 'tasks artifact add',
|
|
552
|
+
});
|
|
553
|
+
if (ctx.json) out(JSON.stringify(data, null, 2));
|
|
554
|
+
else {
|
|
555
|
+
out(`attached ${data.artifact.id}`);
|
|
556
|
+
out(` ${data.artifact.url}${data.artifact.note ? ` — ${data.artifact.note}` : ''}`);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | claim | status | artifact add.`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function cmdDoc(tokens, globals) {
|
|
564
|
+
const [sub, ...rest] = tokens;
|
|
565
|
+
if (!sub || sub === 'list') {
|
|
566
|
+
let limit;
|
|
567
|
+
for (let i = 0; i < rest.length; i++) {
|
|
568
|
+
if (rest[i] === '--limit') limit = rest[++i];
|
|
569
|
+
else throw usageError(`Unexpected ${JSON.stringify(rest[i])}. Usage: takibi doc list [--limit <n>]`);
|
|
570
|
+
}
|
|
571
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
572
|
+
const data = await api('GET', '/v1/documents', {
|
|
573
|
+
...ctx,
|
|
574
|
+
query: qparams([
|
|
575
|
+
['projectId', ctx.projectId],
|
|
576
|
+
['folderId', ctx.folder],
|
|
577
|
+
['limit', limit],
|
|
578
|
+
]),
|
|
579
|
+
label: 'doc list',
|
|
580
|
+
});
|
|
581
|
+
const docs = data.documents ?? [];
|
|
582
|
+
if (ctx.json) {
|
|
583
|
+
out(JSON.stringify({ documents: docs }, null, 2));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (!docs.length) out('No documents.');
|
|
587
|
+
for (const d of docs) out(docLine(d));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (sub === 'get' || sub === 'text') {
|
|
591
|
+
const [id, ...flagTokens] = rest;
|
|
592
|
+
if (!id) throw usageError(`Usage: takibi doc ${sub} <id>`);
|
|
593
|
+
let maxChars;
|
|
594
|
+
for (let i = 0; i < flagTokens.length; i++) {
|
|
595
|
+
if (sub === 'text' && flagTokens[i] === '--max-chars') maxChars = flagTokens[++i];
|
|
596
|
+
else throw usageError(`Unexpected ${JSON.stringify(flagTokens[i])}. Usage: takibi doc ${sub} <id>${sub === 'text' ? ' [--max-chars <n>]' : ''}`);
|
|
597
|
+
}
|
|
598
|
+
const ctx = ctxFor(globals, { needKey: true });
|
|
599
|
+
if (sub === 'get') {
|
|
600
|
+
const data = await api('GET', `/v1/documents/${id}`, { ...ctx, query: qparams([['projectId', ctx.projectId]]), label: 'doc get' });
|
|
601
|
+
if (ctx.json) out(JSON.stringify(data, null, 2));
|
|
602
|
+
else renderDoc(data.document);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const data = await api('GET', `/v1/documents/${id}/text`, {
|
|
606
|
+
...ctx,
|
|
607
|
+
query: qparams([
|
|
608
|
+
['projectId', ctx.projectId],
|
|
609
|
+
['maxChars', maxChars],
|
|
610
|
+
]),
|
|
611
|
+
label: 'doc text',
|
|
612
|
+
});
|
|
613
|
+
if (ctx.json) {
|
|
614
|
+
out(JSON.stringify(data, null, 2));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (data.text === null) {
|
|
618
|
+
out('No converted text for that document.');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
out(data.text);
|
|
622
|
+
if (data.truncated) err('(truncated — the server caps converted text; narrow with search/ask.)');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (sub === 'download' || sub === 'delete' || sub === 'retry' || sub === 'upload') {
|
|
626
|
+
// Account-only by design, and unreachable with a Bearer key: refuse
|
|
627
|
+
// here with the pointer instead of burning a doomed request that the
|
|
628
|
+
// server would answer 401 (account routes never see Bearer callers).
|
|
629
|
+
throw new CliError(`\`doc ${sub}\` is account-only — keys cannot call it.`, {
|
|
630
|
+
hint: sub === 'download' ? 'Use `takibi doc text <id>` for the converted text; ask the account owner for originals.' : 'Ask the account owner to do this in the app.',
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
throw usageError(`Unknown doc command ${JSON.stringify(sub)}. Use list | get | text.`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function cmdProjects(tokens, globals) {
|
|
637
|
+
const { map, warnings, path } = loadProjectMap();
|
|
638
|
+
for (const w of warnings) err(`warning: ${w}`);
|
|
639
|
+
if (tokens[0] === '--add') {
|
|
640
|
+
const [, name, id, extra] = tokens;
|
|
641
|
+
if (!name || !id || extra) throw usageError('Usage: takibi projects --add <name> <uuid>');
|
|
642
|
+
if (name.includes('=') || name.includes('\n')) throw usageError('Project names cannot contain = or newlines.');
|
|
643
|
+
if (!UUID_RE.test(id)) throw usageError(`${JSON.stringify(id)} is not a UUID.`);
|
|
644
|
+
const prior = map.get(name);
|
|
645
|
+
if (prior === id) {
|
|
646
|
+
if (!globals.json) out(`saved ${name} → ${id} (already known)`);
|
|
647
|
+
else out(JSON.stringify({ name, id }, null, 2));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (prior) {
|
|
651
|
+
throw new CliError(`${JSON.stringify(name)} already maps to ${prior} in ${path}.`, {
|
|
652
|
+
hint: 'Edit the file by hand to re-point a name; the CLI never overwrites silently.',
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
mkdirSync(takibiDir(), { recursive: true });
|
|
656
|
+
appendFileSync(path, `${name}=${id}\n`, { mode: 0o600 });
|
|
657
|
+
if (!globals.json) out(`saved ${name} → ${id}`);
|
|
658
|
+
else out(JSON.stringify({ name, id }, null, 2));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (tokens.length > 0) throw usageError(`Unexpected ${JSON.stringify(tokens[0])}. Usage: takibi projects [--add <name> <uuid>]`);
|
|
662
|
+
const entries = [...map.entries()].map(([name, id]) => ({ name, id }));
|
|
663
|
+
if (globals.json) {
|
|
664
|
+
out(JSON.stringify({ projects: entries }, null, 2));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (!entries.length) {
|
|
668
|
+
out('No project names saved.');
|
|
669
|
+
err(`(GET /v1/projects is account-only, so names live in ${path} as name=uuid lines.\nAdd them with \`takibi projects --add <name> <uuid>\` (ask the account owner for UUIDs),\nor pass --project <uuid> directly. Single-grant keys may omit --project.)`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
for (const { name, id } of entries) out(`${name} → ${id}`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function cmdVersion(globals) {
|
|
676
|
+
const baseUrl = resolveBaseUrl();
|
|
677
|
+
const data = await api('GET', '/version', { baseUrl, verbose: globals.verbose, label: 'version' });
|
|
678
|
+
if (globals.json) out(JSON.stringify(data, null, 2));
|
|
679
|
+
else out(`${data.service ?? 'takibi-api'} ${data.version ?? '?'} · jev ${data.jev ?? '?'} · via ${baseUrl}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function main(argv) {
|
|
683
|
+
const { globals, rest } = parseArgv(argv);
|
|
684
|
+
if (globals.version) {
|
|
685
|
+
out(`takibibase ${CLI_VERSION}`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const [cmd, ...tokens] = rest;
|
|
689
|
+
if (globals.help || !cmd) {
|
|
690
|
+
out(HELP);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (cmd === 'ask') return cmdAsk(tokens, globals);
|
|
694
|
+
if (cmd === 'search') return cmdSearch(tokens, globals);
|
|
695
|
+
if (cmd === 'tasks' || cmd === 'task') return cmdTasks(tokens, globals);
|
|
696
|
+
if (cmd === 'doc' || cmd === 'docs') return cmdDoc(tokens, globals);
|
|
697
|
+
if (cmd === 'projects') return cmdProjects(tokens, globals);
|
|
698
|
+
if (cmd === 'version') return cmdVersion(globals);
|
|
699
|
+
throw usageError(`Unknown command ${JSON.stringify(cmd)}. See \`takibi --help\`.`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
await main(process.argv.slice(2));
|
|
704
|
+
} catch (e) {
|
|
705
|
+
if (e instanceof CliError) {
|
|
706
|
+
const { globals } = (() => {
|
|
707
|
+
try {
|
|
708
|
+
return parseArgv(process.argv.slice(2));
|
|
709
|
+
} catch {
|
|
710
|
+
return { globals: { json: false } };
|
|
711
|
+
}
|
|
712
|
+
})();
|
|
713
|
+
if (globals.json) {
|
|
714
|
+
err(JSON.stringify({ error: { code: e.code ?? (e.exitCode === 2 ? 'USAGE' : 'ERROR'), message: e.message, ...(e.hint ? { hint: e.hint } : {}), ...(e.status ? { status: e.status } : {}) } }, null, 2));
|
|
715
|
+
} else {
|
|
716
|
+
err(`takibi: ${e.message}`);
|
|
717
|
+
if (e.hint) err(`hint: ${e.hint}`);
|
|
718
|
+
}
|
|
719
|
+
process.exit(e.exitCode);
|
|
720
|
+
}
|
|
721
|
+
err(`takibi: unexpected failure: ${e?.message ?? e}`);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
}
|