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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/index.js";
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
+ });