listspace 0.1.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 +21 -0
- package/README.md +99 -0
- package/dist/listspace.mjs +976 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sjenkie B.V. (Yellow House Digital)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# listspace
|
|
2
|
+
|
|
3
|
+
The command line for [ListSpace](https://listspace.app), the project board your AI agents keep up to date. People and coding agents use it to read boards, add and move items, work through a board on their own, and undo their own changes. It talks to the ListSpace REST API (`https://api.listspace.app/v1`).
|
|
4
|
+
|
|
5
|
+
Node 20 or later. No runtime dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Run it without installing:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx listspace --help
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install it once and use `listspace` everywhere:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g listspace
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Sign in
|
|
22
|
+
|
|
23
|
+
Create a personal token in ListSpace under **Settings > API**, then save it:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx listspace login --token ls_...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Without `--token`, `login` reads the token from stdin, so it stays out of your shell history:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cat token.txt | npx listspace login
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The token is saved as `credentials.json` (mode 600) in your OS config directory: `%APPDATA%\listspace` on Windows, `~/Library/Application Support/listspace` on macOS and `~/.config/listspace` elsewhere. `listspace logout` removes it. A read-only token works for the read commands only.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Sign in
|
|
41
|
+
login [--token ls_...] Save a personal token (ListSpace: Settings > API).
|
|
42
|
+
logout Forget the saved token.
|
|
43
|
+
whoami Plan, freedom levels, guidance and boards (alias: account).
|
|
44
|
+
|
|
45
|
+
Read
|
|
46
|
+
boards Your boards with their ids.
|
|
47
|
+
session <board> Start a work session: guidance, board instructions,
|
|
48
|
+
level, open work. Run this first.
|
|
49
|
+
board <board> Lists and items with their ids.
|
|
50
|
+
inbox <board> [--status pending|accepted|rejected|withdrawn|expired|failed|all]
|
|
51
|
+
|
|
52
|
+
Write (the board's freedom level decides what is made and what waits in the Inbox)
|
|
53
|
+
new-board <title> [--description <text>]
|
|
54
|
+
add <board> <list> <title> [--description <markdown>]
|
|
55
|
+
move <item> <list>
|
|
56
|
+
done <item> Move to the board's done list.
|
|
57
|
+
undo <request_id> Take back one of your own calls.
|
|
58
|
+
Writes take --inbox --reason "<why>" to suggest instead of change.
|
|
59
|
+
|
|
60
|
+
Work on your own (needs level 4, Act)
|
|
61
|
+
next <board> [--kinds todo,backlog] [--label <id>] [--no-move] [--ttl <minutes>]
|
|
62
|
+
claim <item> [--ttl <minutes>]
|
|
63
|
+
release <item>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`<board>` is an id or a title. `<list>` is an id, a title or a kind (`todo`, `doing`, `done`, `info`, `capture`, `backlog`). `<item>` is an item id. Run `listspace --help` for every option.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx listspace session "Website"
|
|
72
|
+
npx listspace add "Website" ideas "Try dark mode" --description "Seen in **settings**"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Output for agents and scripts
|
|
76
|
+
|
|
77
|
+
Every command takes `--json` and then prints the API's JSON instead of text:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx listspace boards --json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Exit codes: 0 ok, 1 failed, 2 usage error, 3 sign-in needed, 4 rate limited (wait and retry).
|
|
84
|
+
|
|
85
|
+
## Environment
|
|
86
|
+
|
|
87
|
+
| Variable | What it does |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `LISTSPACE_TOKEN` | A token for this run. It wins over the saved one. |
|
|
90
|
+
| `LISTSPACE_API_URL` | Another API address. Default `https://api.listspace.app/v1`. |
|
|
91
|
+
| `LISTSPACE_CONFIG_DIR` | Where `credentials.json` is kept. |
|
|
92
|
+
|
|
93
|
+
## What agents may do
|
|
94
|
+
|
|
95
|
+
Each board has a freedom level that decides what an agent may change on its own. Changes that need your approval wait in the board's Inbox, and every change shows who made it. The rules are in [What AI agents can do](https://listspace.app/docs/agents). Agents that support MCP can also connect to `https://mcp.listspace.app` instead of using this CLI ([setup](https://listspace.app/docs/mcp)).
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT, Sjenkie B.V. (Yellow House Digital). See `LICENSE`.
|
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/main.ts
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
// src/decode.ts
|
|
10
|
+
var DecodeError = class extends Error {
|
|
11
|
+
constructor(path2, expected) {
|
|
12
|
+
super(`Unexpected response from ListSpace: ${path2} should be ${expected}.`);
|
|
13
|
+
this.path = path2;
|
|
14
|
+
this.expected = expected;
|
|
15
|
+
this.name = "DecodeError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
var string = (value, path2) => {
|
|
22
|
+
if (typeof value !== "string") throw new DecodeError(path2, "a string");
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
var number = (value, path2) => {
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new DecodeError(path2, "a number");
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
var boolean = (value, path2) => {
|
|
30
|
+
if (typeof value !== "boolean") throw new DecodeError(path2, "true or false");
|
|
31
|
+
return value;
|
|
32
|
+
};
|
|
33
|
+
function literal(...allowed) {
|
|
34
|
+
return (value, path2) => {
|
|
35
|
+
const match = allowed.find((candidate) => candidate === value);
|
|
36
|
+
if (match === void 0) throw new DecodeError(path2, `one of ${allowed.map((a) => JSON.stringify(a)).join(", ")}`);
|
|
37
|
+
return match;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function nullable(inner) {
|
|
41
|
+
return (value, path2) => value === null ? null : inner(value, path2);
|
|
42
|
+
}
|
|
43
|
+
function withDefault(inner, fallback) {
|
|
44
|
+
return (value, path2) => value === void 0 ? fallback : inner(value, path2);
|
|
45
|
+
}
|
|
46
|
+
function array(inner) {
|
|
47
|
+
return (value, path2) => {
|
|
48
|
+
if (!Array.isArray(value)) throw new DecodeError(path2, "a list");
|
|
49
|
+
return value.map((entry, index) => inner(entry, `${path2}[${index}]`));
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function object(shape) {
|
|
53
|
+
return (value, path2) => {
|
|
54
|
+
if (!isRecord(value)) throw new DecodeError(path2, "an object");
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, decode] of Object.entries(shape)) {
|
|
57
|
+
out[key] = decode(value[key], `${path2}.${key}`);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function union(pick, a, b) {
|
|
63
|
+
return (value, path2) => {
|
|
64
|
+
if (!isRecord(value)) throw new DecodeError(path2, "an object");
|
|
65
|
+
return pick(value) === "a" ? a(value, path2) : b(value, path2);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/schema.ts
|
|
70
|
+
var LIST_KINDS = ["info", "capture", "backlog", "todo", "doing", "done"];
|
|
71
|
+
var listKind = nullable(literal(...LIST_KINDS));
|
|
72
|
+
var level = object({
|
|
73
|
+
effective: literal(1, 2, 3, 4, 5),
|
|
74
|
+
name: string
|
|
75
|
+
});
|
|
76
|
+
var account = object({
|
|
77
|
+
guidance: nullable(string),
|
|
78
|
+
default_level: object({ effective: literal(1, 2, 3, 4, 5), name: string, limited_by_plan: boolean }),
|
|
79
|
+
plan: object({ name: string, max_agent_level: number }),
|
|
80
|
+
boards: array(
|
|
81
|
+
object({
|
|
82
|
+
id: string,
|
|
83
|
+
title: string,
|
|
84
|
+
url: string,
|
|
85
|
+
level,
|
|
86
|
+
follows_account: boolean,
|
|
87
|
+
inbox_pending: number,
|
|
88
|
+
has_instructions: boolean
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
});
|
|
92
|
+
var boardSummary = object({ id: string, title: string, url: string, archived: boolean });
|
|
93
|
+
var cardBrief = object({ id: string, title: string, due_date: nullable(string), done: boolean });
|
|
94
|
+
var boardDetail = object({
|
|
95
|
+
id: string,
|
|
96
|
+
title: string,
|
|
97
|
+
url: string,
|
|
98
|
+
guidance: withDefault(nullable(string), null),
|
|
99
|
+
instructions: withDefault(nullable(string), null),
|
|
100
|
+
level: withDefault(nullable(level), null),
|
|
101
|
+
lists: array(object({ id: string, title: string, kind: listKind, cards: array(cardBrief) })),
|
|
102
|
+
truncated: withDefault(boolean, false)
|
|
103
|
+
});
|
|
104
|
+
var itemBrief = object({
|
|
105
|
+
id: string,
|
|
106
|
+
title: string,
|
|
107
|
+
list_title: string,
|
|
108
|
+
due_date: nullable(string),
|
|
109
|
+
url: string
|
|
110
|
+
});
|
|
111
|
+
var session = object({
|
|
112
|
+
guidance: nullable(string),
|
|
113
|
+
board: object({ id: string, title: string, url: string }),
|
|
114
|
+
instructions: nullable(string),
|
|
115
|
+
level,
|
|
116
|
+
lists: array(object({ id: string, title: string, kind: listKind, open_items: number })),
|
|
117
|
+
work: array(itemBrief),
|
|
118
|
+
captured: array(itemBrief),
|
|
119
|
+
recently_done: array(itemBrief),
|
|
120
|
+
due_soon: array(itemBrief),
|
|
121
|
+
today: string,
|
|
122
|
+
truncated: boolean
|
|
123
|
+
});
|
|
124
|
+
var item = object({ id: string, board_id: string, list_id: string, title: string, url: string });
|
|
125
|
+
var applied = object({
|
|
126
|
+
outcome: literal("applied"),
|
|
127
|
+
level,
|
|
128
|
+
undo: nullable(object({ request_id: string }))
|
|
129
|
+
});
|
|
130
|
+
var suggested = object({
|
|
131
|
+
outcome: literal("suggested"),
|
|
132
|
+
level,
|
|
133
|
+
message: string,
|
|
134
|
+
suggestion: object({ id: string, status: string })
|
|
135
|
+
});
|
|
136
|
+
var writeOutcome = union(
|
|
137
|
+
(value) => value.outcome === "suggested" ? "b" : "a",
|
|
138
|
+
applied,
|
|
139
|
+
suggested
|
|
140
|
+
);
|
|
141
|
+
var createdBoard = object({
|
|
142
|
+
id: string,
|
|
143
|
+
title: string,
|
|
144
|
+
url: string,
|
|
145
|
+
instructions: nullable(string),
|
|
146
|
+
level,
|
|
147
|
+
lists: array(object({ id: string, title: string, kind: listKind })),
|
|
148
|
+
undo: nullable(object({ request_id: string }))
|
|
149
|
+
});
|
|
150
|
+
var inbox = object({
|
|
151
|
+
suggestions: array(
|
|
152
|
+
object({
|
|
153
|
+
id: string,
|
|
154
|
+
action: string,
|
|
155
|
+
item_title: nullable(string),
|
|
156
|
+
list_title: nullable(string),
|
|
157
|
+
reason: nullable(string),
|
|
158
|
+
agent_name: string,
|
|
159
|
+
status: string,
|
|
160
|
+
stale: boolean,
|
|
161
|
+
created_at: string,
|
|
162
|
+
expires_at: string
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
});
|
|
166
|
+
var undoResult = object({
|
|
167
|
+
outcome: literal("undone", "already_undone"),
|
|
168
|
+
undone: array(string),
|
|
169
|
+
level: nullable(level)
|
|
170
|
+
});
|
|
171
|
+
var claimedBy = object({ agent_name: nullable(string), expires_at: string });
|
|
172
|
+
var taken = object({
|
|
173
|
+
outcome: literal("taken"),
|
|
174
|
+
item_id: string,
|
|
175
|
+
claimed_by: claimedBy,
|
|
176
|
+
message: string,
|
|
177
|
+
level
|
|
178
|
+
});
|
|
179
|
+
var claimHeld = object({ outcome: literal("claimed", "renewed"), item_id: string, expires_at: string, level });
|
|
180
|
+
var claimResult = union(
|
|
181
|
+
(value) => value.outcome === "taken" ? "b" : "a",
|
|
182
|
+
claimHeld,
|
|
183
|
+
taken
|
|
184
|
+
);
|
|
185
|
+
var released = object({ outcome: literal("released", "not_claimed"), item_id: string, level });
|
|
186
|
+
var releaseResult = union(
|
|
187
|
+
(value) => value.outcome === "taken" ? "b" : "a",
|
|
188
|
+
released,
|
|
189
|
+
taken
|
|
190
|
+
);
|
|
191
|
+
var nextClaimed = object({
|
|
192
|
+
outcome: literal("claimed"),
|
|
193
|
+
item: object({ id: string, title: string, list_id: string, url: string }),
|
|
194
|
+
expires_at: string,
|
|
195
|
+
moved_to: nullable(object({ list_id: string, list_title: string })),
|
|
196
|
+
note: nullable(string),
|
|
197
|
+
level,
|
|
198
|
+
undo: nullable(object({ request_id: string }))
|
|
199
|
+
});
|
|
200
|
+
var nextEmpty = object({ outcome: literal("empty"), message: string, level });
|
|
201
|
+
var nextResult = union(
|
|
202
|
+
(value) => value.outcome === "empty" ? "b" : "a",
|
|
203
|
+
nextClaimed,
|
|
204
|
+
nextEmpty
|
|
205
|
+
);
|
|
206
|
+
function page(entry) {
|
|
207
|
+
return object({ data: array(entry), next_cursor: nullable(string) });
|
|
208
|
+
}
|
|
209
|
+
var errorBody = object({ error: object({ code: string, message: string }) });
|
|
210
|
+
|
|
211
|
+
// src/version.ts
|
|
212
|
+
var VERSION = "0.1.0";
|
|
213
|
+
|
|
214
|
+
// src/api.ts
|
|
215
|
+
var DEFAULT_API_URL = "https://api.listspace.app/v1";
|
|
216
|
+
var TOKEN_PATTERN = /^ls_[A-Za-z0-9_-]{43}$/;
|
|
217
|
+
function parseToken(raw) {
|
|
218
|
+
const trimmed = raw.trim();
|
|
219
|
+
return TOKEN_PATTERN.test(trimmed) ? trimmed : null;
|
|
220
|
+
}
|
|
221
|
+
var CliFailure = class extends Error {
|
|
222
|
+
constructor(error) {
|
|
223
|
+
super(error.message);
|
|
224
|
+
this.error = error;
|
|
225
|
+
this.name = "CliFailure";
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
var fail = (error) => {
|
|
229
|
+
throw new CliFailure(error);
|
|
230
|
+
};
|
|
231
|
+
var LOGIN_HINT = "Run `listspace login` with a personal token (ListSpace: Settings > API), or set LISTSPACE_TOKEN.";
|
|
232
|
+
var ApiClient = class {
|
|
233
|
+
constructor(options) {
|
|
234
|
+
this.options = options;
|
|
235
|
+
}
|
|
236
|
+
/** Sends one request and decodes `data` from the answer. */
|
|
237
|
+
async data(method, path2, decoder, init = {}) {
|
|
238
|
+
const json = await this.send(method, path2, init);
|
|
239
|
+
if (!isRecord(json) || !("data" in json)) return fail({ kind: "unexpected", message: "Unexpected response from ListSpace: no data." });
|
|
240
|
+
return { value: decodeOrFail(decoder, json.data, "data"), raw: json.data };
|
|
241
|
+
}
|
|
242
|
+
/** Sends one request and decodes the whole body (list endpoints with next_cursor). */
|
|
243
|
+
async body(method, path2, decoder, init = {}) {
|
|
244
|
+
const json = await this.send(method, path2, init);
|
|
245
|
+
return { value: decodeOrFail(decoder, json, "body"), raw: json };
|
|
246
|
+
}
|
|
247
|
+
async send(method, path2, init) {
|
|
248
|
+
const url = new URL(this.options.baseUrl.replace(/\/+$/, "") + path2);
|
|
249
|
+
for (const [key, value] of Object.entries(init.query ?? {})) {
|
|
250
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
251
|
+
}
|
|
252
|
+
const headers = {
|
|
253
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
254
|
+
Accept: "application/json",
|
|
255
|
+
"User-Agent": `listspace-cli/${VERSION}`
|
|
256
|
+
};
|
|
257
|
+
if (init.body !== void 0) headers["Content-Type"] = "application/json";
|
|
258
|
+
let response;
|
|
259
|
+
try {
|
|
260
|
+
response = await this.options.fetch(
|
|
261
|
+
url.toString(),
|
|
262
|
+
init.body === void 0 ? { method, headers } : { method, headers, body: JSON.stringify(init.body) }
|
|
263
|
+
);
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
266
|
+
return fail({ kind: "network", message: `Could not reach ListSpace at ${url.origin}: ${reason}` });
|
|
267
|
+
}
|
|
268
|
+
const text = await response.text();
|
|
269
|
+
let json = null;
|
|
270
|
+
if (text !== "") {
|
|
271
|
+
try {
|
|
272
|
+
json = JSON.parse(text);
|
|
273
|
+
} catch {
|
|
274
|
+
if (response.ok) return fail({ kind: "unexpected", message: `ListSpace answered ${response.status} with something that is not JSON.` });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (response.ok) return json;
|
|
278
|
+
return fail(errorFor(response, json));
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
function decodeOrFail(decoder, value, path2) {
|
|
282
|
+
try {
|
|
283
|
+
return decoder(value, path2);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error instanceof DecodeError) return fail({ kind: "unexpected", message: error.message });
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function errorFor(response, json) {
|
|
290
|
+
let code2 = "unknown";
|
|
291
|
+
let message = `ListSpace answered ${response.status}.`;
|
|
292
|
+
try {
|
|
293
|
+
const parsed = errorBody(json, "body");
|
|
294
|
+
code2 = parsed.error.code;
|
|
295
|
+
message = parsed.error.message;
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
if (response.status === 401) return { kind: "auth", message: `${message} ${LOGIN_HINT}` };
|
|
299
|
+
if (response.status === 429) {
|
|
300
|
+
const header = response.headers.get("Retry-After");
|
|
301
|
+
const seconds = header !== null && /^\d+$/.test(header) ? Number(header) : null;
|
|
302
|
+
const wait = seconds === null ? "Wait a minute" : `Wait ${seconds} seconds`;
|
|
303
|
+
return { kind: "rate_limited", message: `${message} ${wait} before trying again.`, retryAfterSeconds: seconds };
|
|
304
|
+
}
|
|
305
|
+
if (response.status === 403 && code2 === "insufficient_scope") {
|
|
306
|
+
return { kind: "api", status: 403, code: code2, message: `${message} This token is read only; create a read and write token in Settings > API.` };
|
|
307
|
+
}
|
|
308
|
+
return { kind: "api", status: response.status, code: code2, message };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/format.ts
|
|
312
|
+
var levelText = (level2) => `level ${level2.effective} (${level2.name})`;
|
|
313
|
+
function block(title, text) {
|
|
314
|
+
if (text === null || text.trim() === "") return [];
|
|
315
|
+
return [`${title}:`, ...text.trim().split("\n").map((line) => ` ${line}`)];
|
|
316
|
+
}
|
|
317
|
+
function items(title, list, showList) {
|
|
318
|
+
if (list.length === 0) return [];
|
|
319
|
+
return [
|
|
320
|
+
`${title}:`,
|
|
321
|
+
...list.map((entry) => {
|
|
322
|
+
const where = showList ? ` [${entry.list_title}]` : "";
|
|
323
|
+
const due = entry.due_date === null ? "" : ` (due ${entry.due_date})`;
|
|
324
|
+
return ` - ${entry.title}${where}${due} ${entry.id}`;
|
|
325
|
+
})
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
function formatAccount(account2) {
|
|
329
|
+
const lines = [
|
|
330
|
+
`Plan: ${account2.plan.name}`,
|
|
331
|
+
`Default ${levelText(account2.default_level)}${account2.default_level.limited_by_plan ? ", limited by the plan" : ""}`,
|
|
332
|
+
...block("Guidance for agents", account2.guidance),
|
|
333
|
+
`Boards (${account2.boards.length}):`,
|
|
334
|
+
...account2.boards.map((board) => {
|
|
335
|
+
const notes = [levelText(board.level)];
|
|
336
|
+
if (board.inbox_pending > 0) notes.push(`${board.inbox_pending} in Inbox`);
|
|
337
|
+
if (board.has_instructions) notes.push("has instructions");
|
|
338
|
+
return ` - ${board.title} ${board.id} (${notes.join(", ")})`;
|
|
339
|
+
})
|
|
340
|
+
];
|
|
341
|
+
return lines.join("\n");
|
|
342
|
+
}
|
|
343
|
+
function formatBoards(boards) {
|
|
344
|
+
if (boards.length === 0) return 'No boards yet. Create one with `listspace new-board "<title>"`.';
|
|
345
|
+
return boards.map((board) => `${board.title} ${board.id}`).join("\n");
|
|
346
|
+
}
|
|
347
|
+
function formatSession(session2) {
|
|
348
|
+
const lists = session2.lists.map((list) => `${list.title}${list.kind === null ? "" : ` (${list.kind})`}: ${list.open_items}`);
|
|
349
|
+
return [
|
|
350
|
+
`${session2.board.title} ${session2.board.id}`,
|
|
351
|
+
`${session2.board.url}`,
|
|
352
|
+
`Agent ${levelText(session2.level)}. Today is ${session2.today}.`,
|
|
353
|
+
...block("Guidance for agents (read first)", session2.guidance),
|
|
354
|
+
...block("Board instructions (follow these)", session2.instructions),
|
|
355
|
+
`Lists: ${lists.join(", ")}`,
|
|
356
|
+
...items("Work", session2.work, true),
|
|
357
|
+
...items("Captured", session2.captured, true),
|
|
358
|
+
...items("Due soon", session2.due_soon, true),
|
|
359
|
+
...items("Recently done", session2.recently_done, false),
|
|
360
|
+
...session2.truncated ? ["(Some sections were cut; run `listspace board <id>` for everything.)"] : []
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
function formatBoard(board) {
|
|
364
|
+
return [
|
|
365
|
+
`${board.title} ${board.id}`,
|
|
366
|
+
board.url,
|
|
367
|
+
...board.level === null ? [] : [`Agent ${levelText(board.level)}.`],
|
|
368
|
+
...block("Guidance for agents (read first)", board.guidance),
|
|
369
|
+
...block("Board instructions (follow these)", board.instructions),
|
|
370
|
+
...board.lists.flatMap((list) => [
|
|
371
|
+
`${list.title}${list.kind === null ? "" : ` (${list.kind})`} ${list.id}`,
|
|
372
|
+
...list.cards.map((card) => ` - ${card.title}${card.due_date === null ? "" : ` (due ${card.due_date}${card.done ? ", done" : ""})`} ${card.id}`)
|
|
373
|
+
]),
|
|
374
|
+
...board.truncated ? ["(Only the first 1,000 items are shown.)"] : []
|
|
375
|
+
].join("\n");
|
|
376
|
+
}
|
|
377
|
+
function formatWrite(outcome, done) {
|
|
378
|
+
if (outcome.outcome === "suggested") {
|
|
379
|
+
return [
|
|
380
|
+
`Suggested, not made: ${outcome.message}`,
|
|
381
|
+
`It waits in the board's Inbox for the user to accept or reject (${levelText(outcome.level)}). Do not send it again.`,
|
|
382
|
+
`Suggestion ${outcome.suggestion.id}`
|
|
383
|
+
].join("\n");
|
|
384
|
+
}
|
|
385
|
+
const undo = outcome.undo === null ? [] : [`Undo: listspace undo ${outcome.undo.request_id}`];
|
|
386
|
+
return [`${done} (${levelText(outcome.level)}).`, ...undo].join("\n");
|
|
387
|
+
}
|
|
388
|
+
function formatCreatedBoard(board) {
|
|
389
|
+
return [
|
|
390
|
+
`Created board "${board.title}" ${board.id}`,
|
|
391
|
+
board.url,
|
|
392
|
+
`Lists: ${board.lists.map((list) => list.title).join(", ")}`,
|
|
393
|
+
...block("Board instructions (follow these)", board.instructions),
|
|
394
|
+
...board.undo === null ? [] : [`Undo: listspace undo ${board.undo.request_id}`]
|
|
395
|
+
].join("\n");
|
|
396
|
+
}
|
|
397
|
+
function formatInbox(inbox2) {
|
|
398
|
+
if (inbox2.suggestions.length === 0) return "The Inbox is empty.";
|
|
399
|
+
return inbox2.suggestions.map((suggestion) => {
|
|
400
|
+
const target = [suggestion.item_title, suggestion.list_title].filter((part) => part !== null).join(" -> ");
|
|
401
|
+
const lines = [`${suggestion.status}: ${suggestion.action}${target === "" ? "" : ` ${target}`} ${suggestion.id}`];
|
|
402
|
+
if (suggestion.reason !== null) lines.push(` Reason: ${suggestion.reason}`);
|
|
403
|
+
lines.push(` By ${suggestion.agent_name}, ${suggestion.created_at.slice(0, 10)}, expires ${suggestion.expires_at.slice(0, 10)}${suggestion.stale ? ", item changed since" : ""}`);
|
|
404
|
+
return lines.join("\n");
|
|
405
|
+
}).join("\n");
|
|
406
|
+
}
|
|
407
|
+
function formatUndo(result) {
|
|
408
|
+
if (result.outcome === "already_undone") return "Nothing left to undo: it was undone already.";
|
|
409
|
+
return `Undone (${result.undone.length} change${result.undone.length === 1 ? "" : "s"}).`;
|
|
410
|
+
}
|
|
411
|
+
function formatTaken(result) {
|
|
412
|
+
return [
|
|
413
|
+
`Taken: ${result.claimed_by.agent_name ?? "another app or assistant"} is working on this item until ${result.claimed_by.expires_at}.`,
|
|
414
|
+
result.message
|
|
415
|
+
].join("\n");
|
|
416
|
+
}
|
|
417
|
+
function formatNext(result) {
|
|
418
|
+
switch (result.outcome) {
|
|
419
|
+
case "empty":
|
|
420
|
+
return `Nothing left: ${result.message}`;
|
|
421
|
+
case "claimed": {
|
|
422
|
+
const lines = [
|
|
423
|
+
`Claimed "${result.item.title}" ${result.item.id}`,
|
|
424
|
+
result.item.url,
|
|
425
|
+
result.moved_to === null ? "Not moved." : `Moved to ${result.moved_to.list_title}.`,
|
|
426
|
+
`Claim runs out at ${result.expires_at} (${levelText(result.level)}); any write of yours on the item renews it.`
|
|
427
|
+
];
|
|
428
|
+
if (result.note !== null) lines.push(`Note: ${result.note}`);
|
|
429
|
+
if (result.undo !== null) lines.push(`Undo the move: listspace undo ${result.undo.request_id}`);
|
|
430
|
+
lines.push(`When done: listspace release ${result.item.id}`);
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
default: {
|
|
434
|
+
const exhaustive = result;
|
|
435
|
+
return exhaustive;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function formatClaim(result) {
|
|
440
|
+
switch (result.outcome) {
|
|
441
|
+
case "claimed":
|
|
442
|
+
case "renewed":
|
|
443
|
+
return `${result.outcome === "claimed" ? "Claimed" : "Renewed"} ${result.item_id} until ${result.expires_at} (${levelText(result.level)}).`;
|
|
444
|
+
case "taken":
|
|
445
|
+
return formatTaken(result);
|
|
446
|
+
default: {
|
|
447
|
+
const exhaustive = result;
|
|
448
|
+
return exhaustive;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function formatRelease(result) {
|
|
453
|
+
switch (result.outcome) {
|
|
454
|
+
case "released":
|
|
455
|
+
return `Released ${result.item_id}.`;
|
|
456
|
+
case "not_claimed":
|
|
457
|
+
return `${result.item_id} had no claim to release.`;
|
|
458
|
+
case "taken":
|
|
459
|
+
return formatTaken(result);
|
|
460
|
+
default: {
|
|
461
|
+
const exhaustive = result;
|
|
462
|
+
return exhaustive;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/commands.ts
|
|
468
|
+
var inboxFields = (choice) => {
|
|
469
|
+
switch (choice.kind) {
|
|
470
|
+
case "inbox":
|
|
471
|
+
return { inbox: true, reason: choice.reason };
|
|
472
|
+
case "direct":
|
|
473
|
+
return choice.reason === null ? {} : { reason: choice.reason };
|
|
474
|
+
default: {
|
|
475
|
+
const exhaustive = choice;
|
|
476
|
+
return exhaustive;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
481
|
+
var path = (id) => encodeURIComponent(id);
|
|
482
|
+
async function allBoards(client) {
|
|
483
|
+
const boards = [];
|
|
484
|
+
const raw = [];
|
|
485
|
+
let cursor;
|
|
486
|
+
for (let pageNumber = 0; pageNumber < 20; pageNumber += 1) {
|
|
487
|
+
const answer = await client.body("GET", "/boards", page(boardSummary), { query: { limit: 100, cursor } });
|
|
488
|
+
boards.push(...answer.value.data);
|
|
489
|
+
if (isRecord(answer.raw) && Array.isArray(answer.raw.data)) raw.push(...answer.raw.data);
|
|
490
|
+
if (answer.value.next_cursor === null) break;
|
|
491
|
+
cursor = answer.value.next_cursor;
|
|
492
|
+
}
|
|
493
|
+
return { boards, raw };
|
|
494
|
+
}
|
|
495
|
+
async function resolveBoardId(client, ref) {
|
|
496
|
+
if (UUID.test(ref)) return ref;
|
|
497
|
+
const { boards } = await allBoards(client);
|
|
498
|
+
const wanted = ref.trim().toLowerCase();
|
|
499
|
+
const exact = boards.filter((board) => board.title.toLowerCase() === wanted);
|
|
500
|
+
const matches = exact.length > 0 ? exact : boards.filter((board) => board.title.toLowerCase().startsWith(wanted));
|
|
501
|
+
const [first, ...others] = matches;
|
|
502
|
+
if (first === void 0) return fail({ kind: "usage", message: `No board called "${ref}". Run \`listspace boards\` to see them.` });
|
|
503
|
+
if (others.length > 0) {
|
|
504
|
+
return fail({ kind: "usage", message: `"${ref}" matches ${matches.length} boards: ${matches.map((b) => `${b.title} (${b.id})`).join(", ")}. Use the id.` });
|
|
505
|
+
}
|
|
506
|
+
return first.id;
|
|
507
|
+
}
|
|
508
|
+
function resolveList(board, ref) {
|
|
509
|
+
const wanted = ref.trim().toLowerCase();
|
|
510
|
+
const byId = board.lists.find((list) => list.id === ref);
|
|
511
|
+
if (byId !== void 0) return byId;
|
|
512
|
+
const byTitle = board.lists.filter((list) => list.title.toLowerCase() === wanted);
|
|
513
|
+
const kind = LIST_KINDS.find((candidate) => candidate === wanted);
|
|
514
|
+
const candidates = byTitle.length > 0 ? byTitle : kind === void 0 ? [] : board.lists.filter((list) => list.kind === kind);
|
|
515
|
+
const [first, ...others] = candidates;
|
|
516
|
+
const names = board.lists.map((list) => list.title).join(", ");
|
|
517
|
+
if (first === void 0) return fail({ kind: "usage", message: `No list "${ref}" on ${board.title}. Its lists: ${names}.` });
|
|
518
|
+
if (others.length > 0) {
|
|
519
|
+
return fail({ kind: "usage", message: `"${ref}" matches ${candidates.length} lists on ${board.title}: ${candidates.map((l) => l.title).join(", ")}. Use the title or id.` });
|
|
520
|
+
}
|
|
521
|
+
return first;
|
|
522
|
+
}
|
|
523
|
+
var getBoard = (client, boardId) => client.data("GET", `/boards/${path(boardId)}`, boardDetail);
|
|
524
|
+
async function accountCommand(client) {
|
|
525
|
+
const answer = await client.data("GET", "/account", account);
|
|
526
|
+
return { raw: answer.raw, text: formatAccount(answer.value) };
|
|
527
|
+
}
|
|
528
|
+
async function boardsCommand(client) {
|
|
529
|
+
const { boards, raw } = await allBoards(client);
|
|
530
|
+
return { raw: { data: raw }, text: formatBoards(boards) };
|
|
531
|
+
}
|
|
532
|
+
async function sessionCommand(client, input) {
|
|
533
|
+
const boardId = await resolveBoardId(client, input.board);
|
|
534
|
+
const answer = await client.data("GET", `/boards/${path(boardId)}/session`, session);
|
|
535
|
+
return { raw: answer.raw, text: formatSession(answer.value) };
|
|
536
|
+
}
|
|
537
|
+
async function boardCommand(client, input) {
|
|
538
|
+
const boardId = await resolveBoardId(client, input.board);
|
|
539
|
+
const answer = await getBoard(client, boardId);
|
|
540
|
+
return { raw: answer.raw, text: formatBoard(answer.value) };
|
|
541
|
+
}
|
|
542
|
+
async function newBoardCommand(client, input) {
|
|
543
|
+
const body = input.description === null ? { title: input.title } : { title: input.title, description: input.description };
|
|
544
|
+
const answer = await client.data("POST", "/boards", createdBoard, { body });
|
|
545
|
+
return { raw: answer.raw, text: formatCreatedBoard(answer.value) };
|
|
546
|
+
}
|
|
547
|
+
async function addCommand(client, input) {
|
|
548
|
+
const boardId = await resolveBoardId(client, input.board);
|
|
549
|
+
const board = (await getBoard(client, boardId)).value;
|
|
550
|
+
const list = resolveList(board, input.list);
|
|
551
|
+
const body = {
|
|
552
|
+
list_id: list.id,
|
|
553
|
+
title: input.title,
|
|
554
|
+
...input.description === null ? {} : { description: input.description, description_format: "markdown" },
|
|
555
|
+
...inboxFields(input.choice)
|
|
556
|
+
};
|
|
557
|
+
const answer = await client.data("POST", "/items", writeOutcome, { body });
|
|
558
|
+
return { raw: answer.raw, text: formatWrite(answer.value, `Added "${input.title}" to ${list.title}`) };
|
|
559
|
+
}
|
|
560
|
+
async function moveTo(client, itemId, pickList, choice) {
|
|
561
|
+
const current = (await client.data("GET", `/items/${path(itemId)}`, item)).value;
|
|
562
|
+
const board = (await getBoard(client, current.board_id)).value;
|
|
563
|
+
const list = pickList(board);
|
|
564
|
+
const body = { list_id: list.id, position: "top", ...inboxFields(choice) };
|
|
565
|
+
const answer = await client.data("POST", `/items/${path(itemId)}/move`, writeOutcome, { body });
|
|
566
|
+
return { raw: answer.raw, text: formatWrite(answer.value, `Moved "${current.title}" to ${list.title}`) };
|
|
567
|
+
}
|
|
568
|
+
function moveCommand(client, input) {
|
|
569
|
+
return moveTo(client, input.item, (board) => resolveList(board, input.list), input.choice);
|
|
570
|
+
}
|
|
571
|
+
function doneCommand(client, input) {
|
|
572
|
+
return moveTo(
|
|
573
|
+
client,
|
|
574
|
+
input.item,
|
|
575
|
+
(board) => {
|
|
576
|
+
const doneList = board.lists.find((list) => list.kind === "done");
|
|
577
|
+
if (doneList === void 0) {
|
|
578
|
+
return fail({ kind: "usage", message: `${board.title} has no done list. Use \`listspace move <item> <list>\` instead.` });
|
|
579
|
+
}
|
|
580
|
+
return doneList;
|
|
581
|
+
},
|
|
582
|
+
input.choice
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
var INBOX_STATUSES = ["pending", "accepted", "rejected", "withdrawn", "expired", "failed", "all"];
|
|
586
|
+
async function inboxCommand(client, input) {
|
|
587
|
+
const boardId = await resolveBoardId(client, input.board);
|
|
588
|
+
const answer = await client.data("GET", `/boards/${path(boardId)}/inbox`, inbox, { query: { status: input.status } });
|
|
589
|
+
return { raw: answer.raw, text: formatInbox(answer.value) };
|
|
590
|
+
}
|
|
591
|
+
async function undoCommand(client, input) {
|
|
592
|
+
const answer = await client.data("POST", `/activity/${path(input.requestId)}/undo`, undoResult);
|
|
593
|
+
return { raw: answer.raw, text: formatUndo(answer.value) };
|
|
594
|
+
}
|
|
595
|
+
function parseClaimMinutes(raw) {
|
|
596
|
+
if (!/^\d+$/.test(raw.trim())) return null;
|
|
597
|
+
const minutes = Number(raw.trim());
|
|
598
|
+
return minutes >= 5 && minutes <= 1440 ? minutes : null;
|
|
599
|
+
}
|
|
600
|
+
async function nextCommand(client, input) {
|
|
601
|
+
const boardId = await resolveBoardId(client, input.board);
|
|
602
|
+
const body = {
|
|
603
|
+
...input.kinds.length === 0 ? {} : { kinds: input.kinds },
|
|
604
|
+
...input.labelIds.length === 0 ? {} : { label_ids: input.labelIds },
|
|
605
|
+
move_to_doing: input.moveToDoing,
|
|
606
|
+
...input.ttl === null ? {} : { ttl_minutes: input.ttl }
|
|
607
|
+
};
|
|
608
|
+
const answer = await client.data("POST", `/boards/${path(boardId)}/next`, nextResult, { body });
|
|
609
|
+
return { raw: answer.raw, text: formatNext(answer.value) };
|
|
610
|
+
}
|
|
611
|
+
async function claimCommand(client, input) {
|
|
612
|
+
const body = input.ttl === null ? {} : { ttl_minutes: input.ttl };
|
|
613
|
+
const answer = await client.data("POST", `/items/${path(input.item)}/claim`, claimResult, { body });
|
|
614
|
+
return { raw: answer.raw, text: formatClaim(answer.value) };
|
|
615
|
+
}
|
|
616
|
+
async function releaseCommand(client, input) {
|
|
617
|
+
const answer = await client.data("DELETE", `/items/${path(input.item)}/claim`, releaseResult);
|
|
618
|
+
return { raw: answer.raw, text: formatRelease(answer.value) };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/config.ts
|
|
622
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
623
|
+
import { homedir } from "node:os";
|
|
624
|
+
import { join } from "node:path";
|
|
625
|
+
function configDir(env, platform = process.platform, home = homedir()) {
|
|
626
|
+
const override = env.LISTSPACE_CONFIG_DIR;
|
|
627
|
+
if (override !== void 0 && override !== "") return override;
|
|
628
|
+
if (platform === "win32") return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "listspace");
|
|
629
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "listspace");
|
|
630
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
631
|
+
return join(xdg !== void 0 && xdg !== "" ? xdg : join(home, ".config"), "listspace");
|
|
632
|
+
}
|
|
633
|
+
var credentialsPath = (dir) => join(dir, "credentials.json");
|
|
634
|
+
async function loadToken(env, dir) {
|
|
635
|
+
const fromEnv = env.LISTSPACE_TOKEN;
|
|
636
|
+
if (fromEnv !== void 0 && fromEnv !== "") {
|
|
637
|
+
const token2 = parseToken(fromEnv);
|
|
638
|
+
return token2 === null ? { kind: "invalid", where: "LISTSPACE_TOKEN" } : { kind: "env", token: token2 };
|
|
639
|
+
}
|
|
640
|
+
const path2 = credentialsPath(dir);
|
|
641
|
+
let text;
|
|
642
|
+
try {
|
|
643
|
+
text = await readFile(path2, "utf8");
|
|
644
|
+
} catch {
|
|
645
|
+
return { kind: "none" };
|
|
646
|
+
}
|
|
647
|
+
let json;
|
|
648
|
+
try {
|
|
649
|
+
json = JSON.parse(text);
|
|
650
|
+
} catch {
|
|
651
|
+
return { kind: "invalid", where: path2 };
|
|
652
|
+
}
|
|
653
|
+
if (!isRecord(json) || typeof json.token !== "string") return { kind: "invalid", where: path2 };
|
|
654
|
+
const token = parseToken(json.token);
|
|
655
|
+
return token === null ? { kind: "invalid", where: path2 } : { kind: "file", token, path: path2 };
|
|
656
|
+
}
|
|
657
|
+
async function saveToken(dir, token) {
|
|
658
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
659
|
+
const path2 = credentialsPath(dir);
|
|
660
|
+
await writeFile(path2, `${JSON.stringify({ token, saved_at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
|
|
661
|
+
`, { mode: 384 });
|
|
662
|
+
await chmod(path2, 384);
|
|
663
|
+
return path2;
|
|
664
|
+
}
|
|
665
|
+
async function removeToken(dir) {
|
|
666
|
+
const path2 = credentialsPath(dir);
|
|
667
|
+
try {
|
|
668
|
+
await readFile(path2);
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
await rm(path2);
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// src/cli.ts
|
|
677
|
+
var HELP = `listspace ${VERSION}: ListSpace from the command line, for people and coding agents.
|
|
678
|
+
|
|
679
|
+
Usage: listspace <command> [arguments] [--json]
|
|
680
|
+
|
|
681
|
+
Sign in
|
|
682
|
+
login [--token ls_...] Save a personal token (ListSpace: Settings > API).
|
|
683
|
+
Without --token it reads the token from stdin.
|
|
684
|
+
logout Forget the saved token.
|
|
685
|
+
whoami Plan, freedom levels, guidance and boards (alias: account).
|
|
686
|
+
|
|
687
|
+
Read
|
|
688
|
+
boards Your boards with their ids.
|
|
689
|
+
session <board> Start a work session: guidance, board instructions,
|
|
690
|
+
level, open work. Run this first.
|
|
691
|
+
board <board> Lists and items with their ids.
|
|
692
|
+
inbox <board> [--status pending|accepted|rejected|withdrawn|expired|failed|all]
|
|
693
|
+
|
|
694
|
+
Write (the board's freedom level decides what is made and what waits in the Inbox)
|
|
695
|
+
new-board <title> [--description <text>] A board from "Project (for agents)".
|
|
696
|
+
add <board> <list> <title> [--description <markdown>]
|
|
697
|
+
move <item> <list>
|
|
698
|
+
done <item> Move to the board's done list.
|
|
699
|
+
undo <request_id> Take back one of your own calls.
|
|
700
|
+
Writes take --inbox --reason "<why>" to suggest instead of change.
|
|
701
|
+
|
|
702
|
+
Work on your own (needs level 4, Act; other agents leave a claimed item alone)
|
|
703
|
+
next <board> [--kinds todo,backlog] [--label <id>] [--no-move] [--ttl <minutes>]
|
|
704
|
+
Claim the top open item no one is working on and
|
|
705
|
+
move it to Doing. --kinds: lists to take from, in
|
|
706
|
+
order (default todo, then backlog). --label: only
|
|
707
|
+
items with this label (repeat or comma list).
|
|
708
|
+
--no-move: leave it in its list.
|
|
709
|
+
claim <item> [--ttl <minutes>] Claim an item, or renew your claim.
|
|
710
|
+
release <item> Release your claim when you stop (done or not).
|
|
711
|
+
A claim lasts --ttl minutes (5 to 1440, default 120); any write of yours on
|
|
712
|
+
the item renews it.
|
|
713
|
+
|
|
714
|
+
<board> is an id or a title, <list> an id, a title or a kind (todo, doing, done,
|
|
715
|
+
info, capture, backlog), <item> an item id.
|
|
716
|
+
|
|
717
|
+
Options
|
|
718
|
+
--json Print the API's JSON (for agents and scripts).
|
|
719
|
+
--help This help. --version The version.
|
|
720
|
+
|
|
721
|
+
Environment
|
|
722
|
+
LISTSPACE_TOKEN A token for this run (wins over the saved one).
|
|
723
|
+
LISTSPACE_API_URL Default ${DEFAULT_API_URL}
|
|
724
|
+
LISTSPACE_CONFIG_DIR Where credentials.json is kept.
|
|
725
|
+
|
|
726
|
+
Exit codes: 0 ok, 1 failed, 2 usage, 3 sign in needed, 4 rate limited (wait and retry).`;
|
|
727
|
+
var EXIT = {
|
|
728
|
+
usage: 2,
|
|
729
|
+
auth: 3,
|
|
730
|
+
rate_limited: 4,
|
|
731
|
+
api: 1,
|
|
732
|
+
network: 1,
|
|
733
|
+
unexpected: 1
|
|
734
|
+
};
|
|
735
|
+
var OPTIONS = {
|
|
736
|
+
json: { type: "boolean" },
|
|
737
|
+
help: { type: "boolean", short: "h" },
|
|
738
|
+
version: { type: "boolean", short: "v" },
|
|
739
|
+
token: { type: "string" },
|
|
740
|
+
description: { type: "string" },
|
|
741
|
+
inbox: { type: "boolean" },
|
|
742
|
+
reason: { type: "string" },
|
|
743
|
+
status: { type: "string" },
|
|
744
|
+
kinds: { type: "string" },
|
|
745
|
+
label: { type: "string", multiple: true },
|
|
746
|
+
"no-move": { type: "boolean" },
|
|
747
|
+
ttl: { type: "string" }
|
|
748
|
+
};
|
|
749
|
+
var usage = (message) => fail({ kind: "usage", message: `${message} Run \`listspace --help\`.` });
|
|
750
|
+
function positionals(command, given, names) {
|
|
751
|
+
if (given.length !== names.length) {
|
|
752
|
+
const wanted = names.length === 0 ? "no arguments" : names.map((name) => `<${name}>`).join(" ");
|
|
753
|
+
usage(`\`${command}\` takes ${wanted}${given.length > names.length && names.length > 0 ? " (quote text with spaces)" : ""}.`);
|
|
754
|
+
}
|
|
755
|
+
return [...given];
|
|
756
|
+
}
|
|
757
|
+
function choiceFrom(flags) {
|
|
758
|
+
const reason = flags.reason ?? null;
|
|
759
|
+
if (reason !== null && reason.length > 500) usage("--reason is at most 500 characters.");
|
|
760
|
+
if (flags.inbox === true) {
|
|
761
|
+
if (reason === null || reason.trim() === "") return usage('--inbox needs --reason "<why>": the user reads it when deciding.');
|
|
762
|
+
return { kind: "inbox", reason };
|
|
763
|
+
}
|
|
764
|
+
return { kind: "direct", reason };
|
|
765
|
+
}
|
|
766
|
+
function ttlFrom(flags) {
|
|
767
|
+
if (flags.ttl === void 0) return null;
|
|
768
|
+
const minutes = parseClaimMinutes(flags.ttl);
|
|
769
|
+
if (minutes === null) return usage("--ttl is a whole number of minutes from 5 to 1440.");
|
|
770
|
+
return minutes;
|
|
771
|
+
}
|
|
772
|
+
function kindsFrom(flags) {
|
|
773
|
+
if (flags.kinds === void 0) return [];
|
|
774
|
+
const kinds = [];
|
|
775
|
+
for (const part of flags.kinds.split(",")) {
|
|
776
|
+
const wanted = part.trim().toLowerCase();
|
|
777
|
+
const kind = LIST_KINDS.find((candidate) => candidate === wanted);
|
|
778
|
+
if (kind === void 0) return usage(`--kinds takes a comma list of ${LIST_KINDS.join(", ")}; "${part.trim()}" is not one.`);
|
|
779
|
+
if (!kinds.includes(kind)) kinds.push(kind);
|
|
780
|
+
}
|
|
781
|
+
return kinds;
|
|
782
|
+
}
|
|
783
|
+
var labelsFrom = (flags) => [
|
|
784
|
+
...new Set((flags.label ?? []).flatMap((value) => value.split(",")).map((id) => id.trim()).filter((id) => id !== ""))
|
|
785
|
+
];
|
|
786
|
+
async function clientFor(io, dir) {
|
|
787
|
+
const source = await loadToken(io.env, dir);
|
|
788
|
+
switch (source.kind) {
|
|
789
|
+
case "env":
|
|
790
|
+
case "file":
|
|
791
|
+
return new ApiClient({ baseUrl: apiUrl(io.env), token: source.token, fetch: io.fetch });
|
|
792
|
+
case "none":
|
|
793
|
+
return fail({ kind: "auth", message: `Not signed in. ${LOGIN_HINT}` });
|
|
794
|
+
case "invalid":
|
|
795
|
+
return fail({ kind: "auth", message: `The token in ${source.where} is not a ListSpace token (ls_ and 43 characters). ${LOGIN_HINT}` });
|
|
796
|
+
default: {
|
|
797
|
+
const exhaustive = source;
|
|
798
|
+
return exhaustive;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
var apiUrl = (env) => {
|
|
803
|
+
const url = env.LISTSPACE_API_URL;
|
|
804
|
+
return url !== void 0 && url !== "" ? url : DEFAULT_API_URL;
|
|
805
|
+
};
|
|
806
|
+
async function loginMethod(flags, io) {
|
|
807
|
+
if (flags.token !== void 0) return { kind: "token", raw: flags.token };
|
|
808
|
+
const typed = await io.readSecret("Paste a personal token from ListSpace (Settings > API): ");
|
|
809
|
+
if (typed === null || typed.trim() === "") return { kind: "pairing" };
|
|
810
|
+
return { kind: "token", raw: typed };
|
|
811
|
+
}
|
|
812
|
+
async function login(flags, io, dir) {
|
|
813
|
+
const method = await loginMethod(flags, io);
|
|
814
|
+
if (method.kind === "pairing") {
|
|
815
|
+
return usage("No token given. Pass --token ls_... or pipe one in (signing in from the browser comes later).");
|
|
816
|
+
}
|
|
817
|
+
const token = parseToken(method.raw);
|
|
818
|
+
if (token === null) return usage("That is not a ListSpace token: it starts with ls_ and has 43 more characters.");
|
|
819
|
+
const client = new ApiClient({ baseUrl: apiUrl(io.env), token, fetch: io.fetch });
|
|
820
|
+
const answer = await client.data("GET", "/account", account);
|
|
821
|
+
const path2 = await saveToken(dir, token);
|
|
822
|
+
const text = [`Signed in (plan ${answer.value.plan.name}, ${answer.value.boards.length} boards). Token saved in ${path2}.`];
|
|
823
|
+
if (io.env.LISTSPACE_TOKEN !== void 0 && io.env.LISTSPACE_TOKEN !== "") text.push("Note: LISTSPACE_TOKEN is set and wins over the saved token.");
|
|
824
|
+
return { raw: { signed_in: true, path: path2 }, text: text.join("\n") };
|
|
825
|
+
}
|
|
826
|
+
async function dispatch(command, args, flags, io, dir) {
|
|
827
|
+
switch (command) {
|
|
828
|
+
case "login":
|
|
829
|
+
positionals(command, args, []);
|
|
830
|
+
return login(flags, io, dir);
|
|
831
|
+
case "logout": {
|
|
832
|
+
positionals(command, args, []);
|
|
833
|
+
const removed = await removeToken(dir);
|
|
834
|
+
return { raw: { signed_out: removed }, text: removed ? "Signed out: the saved token is gone (revoke it in Settings > API if it leaked)." : "No saved token." };
|
|
835
|
+
}
|
|
836
|
+
case "whoami":
|
|
837
|
+
case "account":
|
|
838
|
+
positionals(command, args, []);
|
|
839
|
+
return accountCommand(await clientFor(io, dir));
|
|
840
|
+
case "boards":
|
|
841
|
+
positionals(command, args, []);
|
|
842
|
+
return boardsCommand(await clientFor(io, dir));
|
|
843
|
+
case "session": {
|
|
844
|
+
const [board] = positionals(command, args, ["board"]);
|
|
845
|
+
return sessionCommand(await clientFor(io, dir), { board });
|
|
846
|
+
}
|
|
847
|
+
case "board": {
|
|
848
|
+
const [board] = positionals(command, args, ["board"]);
|
|
849
|
+
return boardCommand(await clientFor(io, dir), { board });
|
|
850
|
+
}
|
|
851
|
+
case "new-board": {
|
|
852
|
+
const [title] = positionals(command, args, ["title"]);
|
|
853
|
+
return newBoardCommand(await clientFor(io, dir), { title, description: flags.description ?? null });
|
|
854
|
+
}
|
|
855
|
+
case "add": {
|
|
856
|
+
const [board, list, title] = positionals(command, args, ["board", "list", "title"]);
|
|
857
|
+
const choice = choiceFrom(flags);
|
|
858
|
+
return addCommand(await clientFor(io, dir), { board, list, title, description: flags.description ?? null, choice });
|
|
859
|
+
}
|
|
860
|
+
case "move": {
|
|
861
|
+
const [item2, list] = positionals(command, args, ["item", "list"]);
|
|
862
|
+
const choice = choiceFrom(flags);
|
|
863
|
+
return moveCommand(await clientFor(io, dir), { item: item2, list, choice });
|
|
864
|
+
}
|
|
865
|
+
case "done": {
|
|
866
|
+
const [item2] = positionals(command, args, ["item"]);
|
|
867
|
+
const choice = choiceFrom(flags);
|
|
868
|
+
return doneCommand(await clientFor(io, dir), { item: item2, choice });
|
|
869
|
+
}
|
|
870
|
+
case "inbox": {
|
|
871
|
+
const [board] = positionals(command, args, ["board"]);
|
|
872
|
+
const wanted = flags.status ?? "pending";
|
|
873
|
+
const status = INBOX_STATUSES.find((candidate) => candidate === wanted);
|
|
874
|
+
if (status === void 0) return usage(`--status is one of ${INBOX_STATUSES.join(", ")}.`);
|
|
875
|
+
return inboxCommand(await clientFor(io, dir), { board, status });
|
|
876
|
+
}
|
|
877
|
+
case "next": {
|
|
878
|
+
const [board] = positionals(command, args, ["board"]);
|
|
879
|
+
const input = { board, kinds: kindsFrom(flags), labelIds: labelsFrom(flags), moveToDoing: flags["no-move"] !== true, ttl: ttlFrom(flags) };
|
|
880
|
+
return nextCommand(await clientFor(io, dir), input);
|
|
881
|
+
}
|
|
882
|
+
case "claim": {
|
|
883
|
+
const [item2] = positionals(command, args, ["item"]);
|
|
884
|
+
const ttl = ttlFrom(flags);
|
|
885
|
+
return claimCommand(await clientFor(io, dir), { item: item2, ttl });
|
|
886
|
+
}
|
|
887
|
+
case "release": {
|
|
888
|
+
const [item2] = positionals(command, args, ["item"]);
|
|
889
|
+
return releaseCommand(await clientFor(io, dir), { item: item2 });
|
|
890
|
+
}
|
|
891
|
+
case "undo": {
|
|
892
|
+
const [requestId] = positionals(command, args, ["request_id"]);
|
|
893
|
+
return undoCommand(await clientFor(io, dir), { requestId });
|
|
894
|
+
}
|
|
895
|
+
default:
|
|
896
|
+
return usage(`Unknown command "${command}".`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async function run(argv, io) {
|
|
900
|
+
let json = argv.includes("--json");
|
|
901
|
+
try {
|
|
902
|
+
let parsed;
|
|
903
|
+
try {
|
|
904
|
+
parsed = parseArgs({ args: [...argv], options: OPTIONS, allowPositionals: true, strict: true });
|
|
905
|
+
} catch (error) {
|
|
906
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
907
|
+
}
|
|
908
|
+
const flags = parsed.values;
|
|
909
|
+
json = flags.json === true;
|
|
910
|
+
const [command, ...args] = parsed.positionals;
|
|
911
|
+
if (flags.version === true) {
|
|
912
|
+
io.stdout(json ? JSON.stringify({ version: VERSION }) : VERSION);
|
|
913
|
+
return 0;
|
|
914
|
+
}
|
|
915
|
+
if (command === void 0 || command === "help" || flags.help === true) {
|
|
916
|
+
io.stdout(HELP);
|
|
917
|
+
return 0;
|
|
918
|
+
}
|
|
919
|
+
const dir = io.configDir ?? configDir(io.env);
|
|
920
|
+
const output = await dispatch(command, args, flags, io, dir);
|
|
921
|
+
io.stdout(json ? JSON.stringify(output.raw, null, 2) : output.text);
|
|
922
|
+
return 0;
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (!(error instanceof CliFailure)) throw error;
|
|
925
|
+
const failure = error.error;
|
|
926
|
+
if (json) {
|
|
927
|
+
io.stdout(JSON.stringify({ error: errorJson(failure) }, null, 2));
|
|
928
|
+
} else {
|
|
929
|
+
io.stderr(`listspace: ${failure.message}`);
|
|
930
|
+
}
|
|
931
|
+
return EXIT[failure.kind];
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
function errorJson(error) {
|
|
935
|
+
switch (error.kind) {
|
|
936
|
+
case "api":
|
|
937
|
+
return { kind: error.kind, status: error.status, code: error.code, message: error.message };
|
|
938
|
+
case "rate_limited":
|
|
939
|
+
return { kind: error.kind, message: error.message, retry_after_seconds: error.retryAfterSeconds };
|
|
940
|
+
case "usage":
|
|
941
|
+
case "auth":
|
|
942
|
+
case "network":
|
|
943
|
+
case "unexpected":
|
|
944
|
+
return { kind: error.kind, message: error.message };
|
|
945
|
+
default: {
|
|
946
|
+
const exhaustive = error;
|
|
947
|
+
return exhaustive;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/main.ts
|
|
953
|
+
async function readSecret(prompt) {
|
|
954
|
+
if (process.stdin.isTTY) {
|
|
955
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
956
|
+
try {
|
|
957
|
+
return await rl.question(prompt);
|
|
958
|
+
} finally {
|
|
959
|
+
rl.close();
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const chunks = [];
|
|
963
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
964
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
965
|
+
return text === "" ? null : text;
|
|
966
|
+
}
|
|
967
|
+
var code = await run(process.argv.slice(2), {
|
|
968
|
+
env: process.env,
|
|
969
|
+
fetch: (url, init) => fetch(url, init),
|
|
970
|
+
stdout: (text) => process.stdout.write(`${text}
|
|
971
|
+
`),
|
|
972
|
+
stderr: (text) => process.stderr.write(`${text}
|
|
973
|
+
`),
|
|
974
|
+
readSecret
|
|
975
|
+
});
|
|
976
|
+
process.exitCode = code;
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "listspace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ListSpace from the command line: the agentic project memory, for people and coding agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"listspace",
|
|
7
|
+
"cli",
|
|
8
|
+
"ai-agents",
|
|
9
|
+
"project-memory",
|
|
10
|
+
"kanban",
|
|
11
|
+
"boards",
|
|
12
|
+
"mcp",
|
|
13
|
+
"claude",
|
|
14
|
+
"codex",
|
|
15
|
+
"cursor"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://listspace.app/docs/agents",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/listspace/agent/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/listspace/agent.git",
|
|
24
|
+
"directory": "cli"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Sjenkie B.V. (Yellow House Digital)",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"listspace": "dist/listspace.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist/listspace.mjs",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/listspace.mjs --banner:js=\"#!/usr/bin/env node\"",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "tsc -p tsconfig.test.json && node --test build/test/api.test.js build/test/cli.test.js build/test/config.test.js build/test/commands.test.js",
|
|
44
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "20.19.43",
|
|
48
|
+
"esbuild": "0.25.5",
|
|
49
|
+
"typescript": "5.8.3"
|
|
50
|
+
}
|
|
51
|
+
}
|