skyboard-cli 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/README.md +148 -0
- package/bin/sb.js +2 -0
- package/package.json +32 -0
- package/src/__tests__/auth-state.test.ts +111 -0
- package/src/__tests__/card-ref.test.ts +42 -0
- package/src/__tests__/column-match.test.ts +57 -0
- package/src/__tests__/commands.test.ts +326 -0
- package/src/__tests__/helpers.ts +154 -0
- package/src/__tests__/materialize.test.ts +257 -0
- package/src/__tests__/pds.test.ts +213 -0
- package/src/commands/add.ts +47 -0
- package/src/commands/boards.ts +64 -0
- package/src/commands/cards.ts +65 -0
- package/src/commands/cols.ts +51 -0
- package/src/commands/comment.ts +64 -0
- package/src/commands/edit.ts +89 -0
- package/src/commands/login.ts +19 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/mv.ts +84 -0
- package/src/commands/new.ts +80 -0
- package/src/commands/rm.ts +79 -0
- package/src/commands/show.ts +100 -0
- package/src/commands/status.ts +78 -0
- package/src/commands/use.ts +72 -0
- package/src/commands/whoami.ts +22 -0
- package/src/index.ts +143 -0
- package/src/lib/auth.ts +244 -0
- package/src/lib/card-ref.ts +32 -0
- package/src/lib/column-match.ts +45 -0
- package/src/lib/config.ts +182 -0
- package/src/lib/display.ts +112 -0
- package/src/lib/materialize.ts +138 -0
- package/src/lib/pds.ts +440 -0
- package/src/lib/permissions.ts +28 -0
- package/src/lib/schemas.ts +97 -0
- package/src/lib/tid.ts +22 -0
- package/src/lib/types.ts +144 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Skyboard CLI (`sb`)
|
|
2
|
+
|
|
3
|
+
A command-line interface for [Skyboard](https://skyboard.dev), a collaborative kanban board built on [AT Protocol](https://atproto.com/). Manage boards and tasks from your terminal without opening a browser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g skyboard-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
sb login alice.bsky.social # opens browser for AT Protocol OAuth
|
|
15
|
+
sb boards # list your boards
|
|
16
|
+
sb use "Sprint Board" # set default board
|
|
17
|
+
sb cards # view all cards
|
|
18
|
+
sb new "Fix login bug" # create a card
|
|
19
|
+
sb mv 3labc12 done # move it to Done
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Auth
|
|
23
|
+
|
|
24
|
+
The CLI uses AT Protocol OAuth with a loopback redirect. Running `sb login` starts a local server, opens your browser for authorization, and stores the session in `~/.config/skyboard/`. Tokens auto-refresh on subsequent commands.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
sb login alice.bsky.social # opens browser for OAuth
|
|
28
|
+
sb whoami # show handle, DID, and PDS endpoint
|
|
29
|
+
sb logout # clear stored session
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
### Board navigation
|
|
35
|
+
|
|
36
|
+
| Command | Description |
|
|
37
|
+
|---------|-------------|
|
|
38
|
+
| `sb boards` | List all boards (owned + joined) |
|
|
39
|
+
| `sb use <board>` | Set default board for subsequent commands |
|
|
40
|
+
| `sb add <link>` | Join a board by AT URI or web URL |
|
|
41
|
+
| `sb cols` | Show columns with task counts |
|
|
42
|
+
|
|
43
|
+
`sb use` accepts a board name (fuzzy match), rkey, AT URI, or web URL.
|
|
44
|
+
|
|
45
|
+
### Card operations
|
|
46
|
+
|
|
47
|
+
| Command | Description |
|
|
48
|
+
|---------|-------------|
|
|
49
|
+
| `sb cards` | List cards grouped by column |
|
|
50
|
+
| `sb new <title>` | Create a new card |
|
|
51
|
+
| `sb show <ref>` | Show card details, comments, and history |
|
|
52
|
+
| `sb mv <ref> <column>` | Move card to a different column |
|
|
53
|
+
| `sb edit <ref>` | Edit card fields |
|
|
54
|
+
| `sb comment <ref> <text>` | Add a comment |
|
|
55
|
+
| `sb rm <ref>` | Delete a card (owner only) |
|
|
56
|
+
|
|
57
|
+
### Card references
|
|
58
|
+
|
|
59
|
+
Cards are referenced by **TID rkey prefix**, similar to git short hashes. `sb cards` shows 7-character truncated rkeys. A minimum of 4 characters is required; if the prefix is ambiguous, the CLI lists the matches.
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
sb cards
|
|
63
|
+
To Do
|
|
64
|
+
3labc12 Fix login bug
|
|
65
|
+
3labc13 Update docs
|
|
66
|
+
|
|
67
|
+
sb show 3labc12 # exact prefix
|
|
68
|
+
sb mv 3lab done # shorter prefix, if unambiguous
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Column matching
|
|
72
|
+
|
|
73
|
+
Columns can be referenced by:
|
|
74
|
+
- **Exact name** (case-insensitive): `"In Progress"`
|
|
75
|
+
- **Name prefix**: `in` matches `In Progress`
|
|
76
|
+
- **1-based index**: `2` (from `sb cols` output)
|
|
77
|
+
|
|
78
|
+
### Filters
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
sb cards -c "In Progress" # filter by column
|
|
82
|
+
sb cards -l "bug" # filter by label
|
|
83
|
+
sb cards -s "login" # search title and description
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Creating and editing
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
sb new "Fix login bug" # create in first column
|
|
90
|
+
sb new "Update docs" -c done -d "Add examples" # specify column and description
|
|
91
|
+
|
|
92
|
+
sb edit 3lab -t "New title" # edit title
|
|
93
|
+
sb edit 3lab -d "New description" # edit description
|
|
94
|
+
sb edit 3lab -l bug -l urgent # set labels
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## JSON output
|
|
98
|
+
|
|
99
|
+
All commands support `--json` for machine-readable output:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
sb cards --json | jq '.[].cards[].title'
|
|
103
|
+
sb boards --json
|
|
104
|
+
sb whoami --json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Claude Code plugin
|
|
108
|
+
|
|
109
|
+
The `sb` CLI includes a [Claude Code](https://claude.ai/code) plugin so you can manage boards directly from a Claude Code conversation using `/sb:cards`, `/sb:new`, `/sb:mv`, etc.
|
|
110
|
+
|
|
111
|
+
### Install the plugin
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Add the marketplace (once)
|
|
115
|
+
/plugin marketplace add /path/to/skyboard
|
|
116
|
+
|
|
117
|
+
# Install the plugin
|
|
118
|
+
/plugin install sb@skyboard
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or from the terminal:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
claude plugin marketplace add /path/to/skyboard
|
|
125
|
+
claude plugin install sb@skyboard --scope user
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Restart Claude Code after installing. All 15 `sb` commands are available as skills (e.g. `/sb:status`, `/sb:cards`, `/sb:new`).
|
|
129
|
+
|
|
130
|
+
## How it works
|
|
131
|
+
|
|
132
|
+
The CLI authenticates via OAuth and talks directly to AT Protocol PDS endpoints — there is no local database. Each command fetches fresh data from the board owner's PDS and all trusted participants, runs the same `materializeTasks()` per-field LWW merge logic as the web app, and displays the result.
|
|
133
|
+
|
|
134
|
+
Write commands (`new`, `mv`, `edit`, `comment`) create AT Protocol records in your PDS. The web app picks these up in real time via Jetstream, and other CLI users see them on their next command.
|
|
135
|
+
|
|
136
|
+
## Config
|
|
137
|
+
|
|
138
|
+
Session and config are stored in `~/.config/skyboard/`:
|
|
139
|
+
|
|
140
|
+
| File | Purpose |
|
|
141
|
+
|------|---------|
|
|
142
|
+
| `session.json` | Current user (DID, handle, PDS endpoint) |
|
|
143
|
+
| `auth/` | OAuth session tokens (mode 0600) |
|
|
144
|
+
| `config.json` | Default board and known boards list |
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
GPL-3.0-only
|
package/bin/sb.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skyboard-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Skyboard – a collaborative kanban board on AT Protocol",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Tim Disney"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"sb": "./bin/sb.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsc --watch",
|
|
15
|
+
"test": "vitest run"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@atproto/api": "^0.13.20",
|
|
19
|
+
"@atproto/common-web": "^0.4.0",
|
|
20
|
+
"@atproto/oauth-client-node": "^0.3.7",
|
|
21
|
+
"chalk": "^5.4.1",
|
|
22
|
+
"commander": "^13.1.0",
|
|
23
|
+
"fractional-indexing": "^3.2.0",
|
|
24
|
+
"open": "^10.1.0",
|
|
25
|
+
"zod": "^3.24.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"typescript": "^5.7.3",
|
|
30
|
+
"vitest": "^3.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { OWNER_DID, USER_DID, BOARD_RKEY } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
// Mock config module
|
|
5
|
+
const mockLoadAuthInfo = vi.fn();
|
|
6
|
+
const mockClearDefaultBoard = vi.fn();
|
|
7
|
+
const mockGetDefaultBoard = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("../lib/config.js", () => ({
|
|
10
|
+
loadAuthInfo: (...args: unknown[]) => mockLoadAuthInfo(...args),
|
|
11
|
+
clearDefaultBoard: (...args: unknown[]) => mockClearDefaultBoard(...args),
|
|
12
|
+
getDefaultBoard: (...args: unknown[]) => mockGetDefaultBoard(...args),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock auth module
|
|
16
|
+
const mockLogin = vi.fn();
|
|
17
|
+
const mockLogout = vi.fn();
|
|
18
|
+
|
|
19
|
+
vi.mock("../lib/auth.js", () => ({
|
|
20
|
+
login: (...args: unknown[]) => mockLogin(...args),
|
|
21
|
+
logout: (...args: unknown[]) => mockLogout(...args),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { logoutCommand } from "../commands/logout.js";
|
|
25
|
+
import { loginCommand } from "../commands/login.js";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
30
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("logoutCommand", () => {
|
|
34
|
+
it("clears the default board on logout", () => {
|
|
35
|
+
mockLoadAuthInfo.mockReturnValue({
|
|
36
|
+
did: OWNER_DID,
|
|
37
|
+
handle: "owner.test",
|
|
38
|
+
service: "https://pds.example.com",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
logoutCommand();
|
|
42
|
+
|
|
43
|
+
expect(mockLogout).toHaveBeenCalledOnce();
|
|
44
|
+
expect(mockClearDefaultBoard).toHaveBeenCalledOnce();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not clear board if not logged in", () => {
|
|
48
|
+
mockLoadAuthInfo.mockReturnValue(null);
|
|
49
|
+
|
|
50
|
+
logoutCommand();
|
|
51
|
+
|
|
52
|
+
expect(mockLogout).not.toHaveBeenCalled();
|
|
53
|
+
expect(mockClearDefaultBoard).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("loginCommand", () => {
|
|
58
|
+
it("clears the default board when switching to a different account", async () => {
|
|
59
|
+
mockLoadAuthInfo.mockReturnValue({
|
|
60
|
+
did: OWNER_DID,
|
|
61
|
+
handle: "owner.test",
|
|
62
|
+
service: "https://pds.example.com",
|
|
63
|
+
});
|
|
64
|
+
mockLogin.mockResolvedValue({ did: USER_DID, handle: "user.test" });
|
|
65
|
+
|
|
66
|
+
await loginCommand("user.test");
|
|
67
|
+
|
|
68
|
+
expect(mockClearDefaultBoard).toHaveBeenCalledOnce();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("does not clear the default board when logging in as the same account", async () => {
|
|
72
|
+
mockLoadAuthInfo.mockReturnValue({
|
|
73
|
+
did: OWNER_DID,
|
|
74
|
+
handle: "owner.test",
|
|
75
|
+
service: "https://pds.example.com",
|
|
76
|
+
});
|
|
77
|
+
mockLogin.mockResolvedValue({ did: OWNER_DID, handle: "owner.test" });
|
|
78
|
+
|
|
79
|
+
await loginCommand("owner.test");
|
|
80
|
+
|
|
81
|
+
expect(mockClearDefaultBoard).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not clear the default board on first login (no previous auth)", async () => {
|
|
85
|
+
mockLoadAuthInfo.mockReturnValue(null);
|
|
86
|
+
mockLogin.mockResolvedValue({ did: OWNER_DID, handle: "owner.test" });
|
|
87
|
+
|
|
88
|
+
await loginCommand("owner.test");
|
|
89
|
+
|
|
90
|
+
expect(mockClearDefaultBoard).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not clear the default board if login fails", async () => {
|
|
94
|
+
mockLoadAuthInfo.mockReturnValue({
|
|
95
|
+
did: OWNER_DID,
|
|
96
|
+
handle: "owner.test",
|
|
97
|
+
service: "https://pds.example.com",
|
|
98
|
+
});
|
|
99
|
+
mockLogin.mockRejectedValue(new Error("OAuth failed"));
|
|
100
|
+
|
|
101
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
102
|
+
throw new Error("process.exit");
|
|
103
|
+
}) as any);
|
|
104
|
+
|
|
105
|
+
await expect(loginCommand("user.test")).rejects.toThrow("process.exit");
|
|
106
|
+
|
|
107
|
+
expect(mockClearDefaultBoard).not.toHaveBeenCalled();
|
|
108
|
+
|
|
109
|
+
exitSpy.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveCardRef } from "../lib/card-ref.js";
|
|
3
|
+
import { makeMaterializedTask, TASK_RKEY_1, TASK_RKEY_2, TASK_RKEY_3 } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
describe("resolveCardRef", () => {
|
|
6
|
+
const tasks = [
|
|
7
|
+
makeMaterializedTask({ rkey: TASK_RKEY_1, title: "First Task" }),
|
|
8
|
+
makeMaterializedTask({ rkey: TASK_RKEY_2, title: "Second Task" }),
|
|
9
|
+
makeMaterializedTask({ rkey: TASK_RKEY_3, title: "Third Task" }),
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
it("matches a task by 4+ character rkey prefix", () => {
|
|
13
|
+
// TASK_RKEY_1 = "3jui7a2zbbb22" — need enough chars to disambiguate from TASK_RKEY_2 "3jui7a2zbbb33"
|
|
14
|
+
const result = resolveCardRef(TASK_RKEY_1.slice(0, 12), tasks);
|
|
15
|
+
expect(result.rkey).toBe(TASK_RKEY_1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("matches a task by full rkey", () => {
|
|
19
|
+
const result = resolveCardRef(TASK_RKEY_2, tasks);
|
|
20
|
+
expect(result.rkey).toBe(TASK_RKEY_2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("throws on too-short prefix (< 4 chars)", () => {
|
|
24
|
+
expect(() => resolveCardRef("3ju", tasks)).toThrow("too short");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws when no task matches", () => {
|
|
28
|
+
expect(() => resolveCardRef("zzzz", tasks)).toThrow('No card found');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("throws on ambiguous match with list of candidates", () => {
|
|
32
|
+
// TASK_RKEY_1 = "3jui7a2zbbb22", TASK_RKEY_2 = "3jui7a2zbbb33"
|
|
33
|
+
// They share prefix "3jui7a2zbbb"
|
|
34
|
+
expect(() => resolveCardRef("3jui7a2zbbb", tasks)).toThrow("Ambiguous");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("resolves unambiguous prefix that partially overlaps", () => {
|
|
38
|
+
// TASK_RKEY_3 = "3jui7a2zbcc44" — prefix "3jui7a2zbcc" is unique
|
|
39
|
+
const result = resolveCardRef("3jui7a2zbcc", tasks);
|
|
40
|
+
expect(result.rkey).toBe(TASK_RKEY_3);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveColumn } from "../lib/column-match.js";
|
|
3
|
+
import { COLUMNS } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
describe("resolveColumn", () => {
|
|
6
|
+
// COLUMNS: To Do (0), In Progress (1), Done (2)
|
|
7
|
+
|
|
8
|
+
it("matches by 1-based numeric index", () => {
|
|
9
|
+
expect(resolveColumn("1", COLUMNS)).toEqual(COLUMNS[0]); // To Do
|
|
10
|
+
expect(resolveColumn("2", COLUMNS)).toEqual(COLUMNS[1]); // In Progress
|
|
11
|
+
expect(resolveColumn("3", COLUMNS)).toEqual(COLUMNS[2]); // Done
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("matches by exact name (case-insensitive)", () => {
|
|
15
|
+
expect(resolveColumn("To Do", COLUMNS)).toEqual(COLUMNS[0]);
|
|
16
|
+
expect(resolveColumn("to do", COLUMNS)).toEqual(COLUMNS[0]);
|
|
17
|
+
expect(resolveColumn("IN PROGRESS", COLUMNS)).toEqual(COLUMNS[1]);
|
|
18
|
+
expect(resolveColumn("done", COLUMNS)).toEqual(COLUMNS[2]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("matches by prefix", () => {
|
|
22
|
+
expect(resolveColumn("Do", COLUMNS)).toEqual(COLUMNS[2]); // "Done" unique prefix
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("matches by substring", () => {
|
|
26
|
+
expect(resolveColumn("Prog", COLUMNS)).toEqual(COLUMNS[1]); // "In Progress"
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("throws on ambiguous prefix match", () => {
|
|
30
|
+
// Columns with ambiguous prefixes
|
|
31
|
+
const cols = [
|
|
32
|
+
{ id: "a", name: "Alpha One", order: 0 },
|
|
33
|
+
{ id: "b", name: "Alpha Two", order: 1 },
|
|
34
|
+
];
|
|
35
|
+
expect(() => resolveColumn("Alpha", cols)).toThrow("Ambiguous");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws on ambiguous substring match", () => {
|
|
39
|
+
const cols = [
|
|
40
|
+
{ id: "a", name: "Pre-Review", order: 0 },
|
|
41
|
+
{ id: "b", name: "Post-Review", order: 1 },
|
|
42
|
+
];
|
|
43
|
+
// "Review" is a substring of both but not a prefix of either
|
|
44
|
+
expect(() => resolveColumn("Review", cols)).toThrow("Ambiguous");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws on no match with available columns", () => {
|
|
48
|
+
expect(() => resolveColumn("Nonexistent", COLUMNS)).toThrow("No column matching");
|
|
49
|
+
expect(() => resolveColumn("Nonexistent", COLUMNS)).toThrow("To Do");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("ignores out-of-range numeric index", () => {
|
|
53
|
+
// "0" is out of 1-based range, and doesn't match any name
|
|
54
|
+
expect(() => resolveColumn("0", COLUMNS)).toThrow("No column matching");
|
|
55
|
+
expect(() => resolveColumn("99", COLUMNS)).toThrow("No column matching");
|
|
56
|
+
});
|
|
57
|
+
});
|