fishladder 0.1.0 → 0.2.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/bin/fish.js +1 -1
- package/package.json +1 -1
- package/src/commands/feedback.js +79 -0
- package/src/commands/hiring.js +59 -0
- package/src/commands/tasks.js +71 -3
- package/src/smart-syntax.js +55 -0
- package/CLAUDE.md +0 -113
package/bin/fish.js
CHANGED
|
@@ -12,7 +12,7 @@ import { registerHiringCommand } from '../src/commands/hiring.js';
|
|
|
12
12
|
program
|
|
13
13
|
.name('fish')
|
|
14
14
|
.description('Fishladder CLI — manage projects, tasks, feedback, and hiring')
|
|
15
|
-
.version('0.
|
|
15
|
+
.version('0.2.0');
|
|
16
16
|
|
|
17
17
|
registerLoginCommand(program);
|
|
18
18
|
registerLogoutCommand(program);
|
package/package.json
CHANGED
package/src/commands/feedback.js
CHANGED
|
@@ -61,6 +61,85 @@ export function registerFeedbackCommand(program) {
|
|
|
61
61
|
|
|
62
62
|
console.log(`Feedback created: ${entry.title} (${entry.id.slice(0, 8)})`);
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
feedback
|
|
66
|
+
.command('analyze')
|
|
67
|
+
.description('Run AI analysis on feedback (supports stdin)')
|
|
68
|
+
.option('--ids <ids>', 'Comma-separated feedback IDs to analyze')
|
|
69
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
70
|
+
.action(async (opts) => {
|
|
71
|
+
const body = {};
|
|
72
|
+
|
|
73
|
+
if (opts.ids) {
|
|
74
|
+
body.feedbackIds = opts.ids.split(',').map(id => id.trim());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read from stdin if piped
|
|
78
|
+
if (!process.stdin.isTTY) {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
for await (const chunk of process.stdin) {
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
}
|
|
83
|
+
const input = Buffer.concat(chunks).toString('utf-8').trim();
|
|
84
|
+
if (input) {
|
|
85
|
+
// Try to parse as JSON array of IDs, otherwise treat as raw text
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(input);
|
|
88
|
+
if (Array.isArray(parsed)) {
|
|
89
|
+
body.feedbackIds = parsed.map(item =>
|
|
90
|
+
typeof item === 'string' ? item : item.id
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
body.text = input;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
body.text = input;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!body.feedbackIds && !body.text) {
|
|
102
|
+
console.error('Provide feedback IDs with --ids or pipe data via stdin.');
|
|
103
|
+
console.error('Examples:');
|
|
104
|
+
console.error(' fish feedback analyze --ids abc123,def456');
|
|
105
|
+
console.error(' fish feedback list --format json | fish feedback analyze');
|
|
106
|
+
console.error(' echo "Users want dark mode" | fish feedback analyze');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = await request('/feedback/analyze', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: JSON.stringify(body),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (opts.format === 'json') {
|
|
116
|
+
formatJson(result);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.summary) {
|
|
121
|
+
console.log('\nSummary');
|
|
122
|
+
console.log('-------');
|
|
123
|
+
console.log(result.summary);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (result.themes?.length > 0) {
|
|
127
|
+
console.log('\nThemes');
|
|
128
|
+
console.log('------');
|
|
129
|
+
formatTable(
|
|
130
|
+
['Theme', 'Count', 'Impact'],
|
|
131
|
+
result.themes.map(t => [t.name, t.count ?? '-', t.impact ?? '-'])
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (result.recommendations?.length > 0) {
|
|
136
|
+
console.log('\nRecommendations');
|
|
137
|
+
console.log('---------------');
|
|
138
|
+
result.recommendations.forEach((r, i) => {
|
|
139
|
+
console.log(`${i + 1}. ${r}`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
64
143
|
}
|
|
65
144
|
|
|
66
145
|
function truncate(str, len) {
|
package/src/commands/hiring.js
CHANGED
|
@@ -98,6 +98,65 @@ export function registerHiringCommand(program) {
|
|
|
98
98
|
])
|
|
99
99
|
);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
hiring
|
|
103
|
+
.command('move <applicationId>')
|
|
104
|
+
.description('Move an application to a different stage')
|
|
105
|
+
.requiredOption('--stage <stageId>', 'Target stage ID')
|
|
106
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
107
|
+
.action(async (applicationId, opts) => {
|
|
108
|
+
const result = await request(`/applications/${applicationId}`, {
|
|
109
|
+
method: 'PATCH',
|
|
110
|
+
body: JSON.stringify({ stageId: opts.stage }),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (opts.format === 'json') {
|
|
114
|
+
formatJson(result);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`Application ${result.id.slice(0, 8)} moved to stage ${result.stageId?.slice(0, 8) ?? opts.stage}.`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
hiring
|
|
122
|
+
.command('hire <applicationId>')
|
|
123
|
+
.description('Mark an application as hired')
|
|
124
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
125
|
+
.action(async (applicationId, opts) => {
|
|
126
|
+
const result = await request(`/applications/${applicationId}`, {
|
|
127
|
+
method: 'PATCH',
|
|
128
|
+
body: JSON.stringify({ status: 'hired' }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (opts.format === 'json') {
|
|
132
|
+
formatJson(result);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(`Application ${result.id.slice(0, 8)} marked as hired.`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
hiring
|
|
140
|
+
.command('reject <applicationId>')
|
|
141
|
+
.description('Reject an application')
|
|
142
|
+
.option('--reason <text>', 'Rejection reason')
|
|
143
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
144
|
+
.action(async (applicationId, opts) => {
|
|
145
|
+
const body = { status: 'rejected' };
|
|
146
|
+
if (opts.reason) body.rejectionReason = opts.reason;
|
|
147
|
+
|
|
148
|
+
const result = await request(`/applications/${applicationId}`, {
|
|
149
|
+
method: 'PATCH',
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (opts.format === 'json') {
|
|
154
|
+
formatJson(result);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(`Application ${result.id.slice(0, 8)} rejected.`);
|
|
159
|
+
});
|
|
101
160
|
}
|
|
102
161
|
|
|
103
162
|
function truncate(str, len) {
|
package/src/commands/tasks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { request } from '../api.js';
|
|
2
2
|
import { formatTable, formatJson } from '../format.js';
|
|
3
|
+
import { parseSmartSyntax } from '../smart-syntax.js';
|
|
3
4
|
|
|
4
5
|
export function registerTasksCommand(program) {
|
|
5
6
|
const tasks = program
|
|
@@ -42,21 +43,34 @@ export function registerTasksCommand(program) {
|
|
|
42
43
|
|
|
43
44
|
tasks
|
|
44
45
|
.command('create <title>')
|
|
45
|
-
.description('Create a new task')
|
|
46
|
+
.description('Create a new task (supports smart syntax: @user !priority #tag due:date)')
|
|
46
47
|
.requiredOption('--project <id>', 'Project ID (required)')
|
|
47
48
|
.option('--assign <userId>', 'Assignee user ID')
|
|
48
49
|
.option('--priority <level>', 'Priority: none, low, medium, high, urgent', 'none')
|
|
49
50
|
.option('--due <date>', 'Due date (YYYY-MM-DD)')
|
|
50
51
|
.option('--format <format>', 'Output format: table or json', 'table')
|
|
51
|
-
.
|
|
52
|
+
.allowUnknownOption(false)
|
|
53
|
+
.action(async (title, opts, cmd) => {
|
|
54
|
+
// Parse smart syntax from extra args on the raw command line
|
|
55
|
+
const extra = cmd.args.filter(a => a !== title);
|
|
56
|
+
const smart = parseSmartSyntax(extra);
|
|
57
|
+
|
|
52
58
|
const body = {
|
|
53
59
|
title,
|
|
54
60
|
projectId: opts.project,
|
|
55
61
|
};
|
|
56
62
|
|
|
63
|
+
// Smart syntax values are overridden by explicit flags
|
|
57
64
|
if (opts.assign) body.assigneeId = opts.assign;
|
|
58
|
-
if (
|
|
65
|
+
else if (smart.assignee) body.assigneeId = smart.assignee;
|
|
66
|
+
|
|
67
|
+
if (opts.priority && opts.priority !== 'none') body.priority = opts.priority;
|
|
68
|
+
else if (smart.priority) body.priority = smart.priority;
|
|
69
|
+
|
|
59
70
|
if (opts.due) body.dueDate = opts.due;
|
|
71
|
+
else if (smart.due) body.dueDate = smart.due;
|
|
72
|
+
|
|
73
|
+
if (smart.tags.length > 0) body.tags = smart.tags;
|
|
60
74
|
|
|
61
75
|
const task = await request('/tasks', {
|
|
62
76
|
method: 'POST',
|
|
@@ -70,6 +84,60 @@ export function registerTasksCommand(program) {
|
|
|
70
84
|
|
|
71
85
|
console.log(`Task created: ${task.title} (${task.id.slice(0, 8)})`);
|
|
72
86
|
});
|
|
87
|
+
|
|
88
|
+
tasks
|
|
89
|
+
.command('update <id>')
|
|
90
|
+
.description('Update an existing task')
|
|
91
|
+
.option('--title <title>', 'New title')
|
|
92
|
+
.option('--assign <userId>', 'Reassign to user ID')
|
|
93
|
+
.option('--priority <level>', 'Priority: none, low, medium, high, urgent')
|
|
94
|
+
.option('--due <date>', 'Due date (YYYY-MM-DD)')
|
|
95
|
+
.option('--status <status>', 'Status (e.g. todo, in_progress, done)')
|
|
96
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
97
|
+
.action(async (id, opts) => {
|
|
98
|
+
const body = {};
|
|
99
|
+
|
|
100
|
+
if (opts.title) body.title = opts.title;
|
|
101
|
+
if (opts.assign) body.assigneeId = opts.assign;
|
|
102
|
+
if (opts.priority) body.priority = opts.priority;
|
|
103
|
+
if (opts.due) body.dueDate = opts.due;
|
|
104
|
+
if (opts.status) body.status = opts.status;
|
|
105
|
+
|
|
106
|
+
if (Object.keys(body).length === 0) {
|
|
107
|
+
console.error('No fields to update. Use --title, --assign, --priority, --due, or --status.');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const task = await request(`/tasks/${id}`, {
|
|
112
|
+
method: 'PATCH',
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (opts.format === 'json') {
|
|
117
|
+
formatJson(task);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`Task updated: ${task.title} (${task.id.slice(0, 8)})`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
tasks
|
|
125
|
+
.command('complete <id>')
|
|
126
|
+
.description('Mark a task as complete')
|
|
127
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
128
|
+
.action(async (id, opts) => {
|
|
129
|
+
const task = await request(`/tasks/${id}`, {
|
|
130
|
+
method: 'PATCH',
|
|
131
|
+
body: JSON.stringify({ status: 'done' }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (opts.format === 'json') {
|
|
135
|
+
formatJson(task);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`Task completed: ${task.title} (${task.id.slice(0, 8)})`);
|
|
140
|
+
});
|
|
73
141
|
}
|
|
74
142
|
|
|
75
143
|
function truncate(str, len) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Parses smart syntax tokens from variadic args
|
|
2
|
+
// e.g. "Fix bug" @sarah !high #backend due:friday
|
|
3
|
+
// Returns { assignee, priority, tags, due }
|
|
4
|
+
|
|
5
|
+
const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
6
|
+
|
|
7
|
+
function resolveDate(value) {
|
|
8
|
+
const lower = value.toLowerCase();
|
|
9
|
+
|
|
10
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(lower)) return lower;
|
|
11
|
+
|
|
12
|
+
const today = new Date();
|
|
13
|
+
|
|
14
|
+
if (lower === 'today') {
|
|
15
|
+
return fmt(today);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (lower === 'tomorrow') {
|
|
19
|
+
today.setDate(today.getDate() + 1);
|
|
20
|
+
return fmt(today);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const dayIndex = DAY_NAMES.indexOf(lower);
|
|
24
|
+
if (dayIndex !== -1) {
|
|
25
|
+
const current = today.getDay();
|
|
26
|
+
let diff = dayIndex - current;
|
|
27
|
+
if (diff <= 0) diff += 7;
|
|
28
|
+
today.setDate(today.getDate() + diff);
|
|
29
|
+
return fmt(today);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return value; // pass through as-is if not recognized
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fmt(date) {
|
|
36
|
+
return date.toISOString().split('T')[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseSmartSyntax(tokens) {
|
|
40
|
+
const result = { tags: [] };
|
|
41
|
+
|
|
42
|
+
for (const token of tokens) {
|
|
43
|
+
if (token.startsWith('@')) {
|
|
44
|
+
result.assignee = token.slice(1);
|
|
45
|
+
} else if (token.startsWith('!')) {
|
|
46
|
+
result.priority = token.slice(1).toLowerCase();
|
|
47
|
+
} else if (token.startsWith('#')) {
|
|
48
|
+
result.tags.push(token.slice(1));
|
|
49
|
+
} else if (token.startsWith('due:')) {
|
|
50
|
+
result.due = resolveDate(token.slice(4));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
package/CLAUDE.md
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# Fishladder CLI — Project Context for Claude
|
|
2
|
-
|
|
3
|
-
## What This Is
|
|
4
|
-
|
|
5
|
-
A CLI tool for the Fishladder app (https://app.ladder.fish). npm package name: `fishladder`, binary: `fish`. Installed via `npm install -g fishladder`.
|
|
6
|
-
|
|
7
|
-
**Spec document**: The full spec lives in the main Fishladder app repo at `docs/phase-5-cli-tool.md`. This CLI implements Track B of that spec. Track A (the `/cli-auth` backend endpoint) lives in the main app.
|
|
8
|
-
|
|
9
|
-
**Main app repo**: `github.com/prone/fishladder` — the CLI talks to its `/api/v1/` endpoints.
|
|
10
|
-
|
|
11
|
-
## Architecture
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
fishladder-cli/
|
|
15
|
-
bin/fish.js — entry point (#!/usr/bin/env node), registers all commands
|
|
16
|
-
src/
|
|
17
|
-
auth.js — login (browser OAuth → local HTTP server → keytar), logout, getToken()
|
|
18
|
-
api.js — fetch wrapper with Bearer auth, 401/429 error handling
|
|
19
|
-
format.js — table output (cli-table3) + --format json
|
|
20
|
-
commands/
|
|
21
|
-
login.js — fish login (browser OAuth flow)
|
|
22
|
-
logout.js — fish logout (remove keychain token)
|
|
23
|
-
whoami.js — fish whoami (GET /members)
|
|
24
|
-
projects.js — fish projects list (GET /projects)
|
|
25
|
-
tasks.js — fish tasks list|create (GET|POST /tasks)
|
|
26
|
-
feedback.js — fish feedback list|create (GET|POST /feedback)
|
|
27
|
-
hiring.js — fish hiring jobs|candidates|pipeline (GET /jobs, /candidates, /applications)
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Tech Stack
|
|
31
|
-
|
|
32
|
-
- **Runtime**: Node.js >=18, ESM (`"type": "module"`)
|
|
33
|
-
- **CLI framework**: Commander.js
|
|
34
|
-
- **Secure token storage**: keytar (system keychain — never write tokens to disk)
|
|
35
|
-
- **Output**: chalk (colors), cli-table3 (tables), `--format json` on all list commands
|
|
36
|
-
- **Browser launch**: open (for OAuth login flow)
|
|
37
|
-
- **Tests**: vitest (not yet written)
|
|
38
|
-
|
|
39
|
-
## Auth Flow
|
|
40
|
-
|
|
41
|
-
### Browser OAuth (`fish login`)
|
|
42
|
-
1. CLI generates random `state`, finds available port (9876–9885)
|
|
43
|
-
2. Starts local HTTP server on that port
|
|
44
|
-
3. Opens browser to `https://app.ladder.fish/cli-auth?state=<state>&port=<port>`
|
|
45
|
-
4. User authenticates in browser (or is already logged in)
|
|
46
|
-
5. App generates `fl_` prefixed API key, redirects to `http://localhost:<port>/callback?token=<token>&state=<state>`
|
|
47
|
-
6. CLI validates state match, stores token in system keychain via keytar
|
|
48
|
-
|
|
49
|
-
### Environment variable fallback
|
|
50
|
-
Set `FISHLADDER_API_KEY=fl_<key>` to bypass browser login. Useful for development and CI. The `getToken()` function checks the env var first, then keytar.
|
|
51
|
-
|
|
52
|
-
### Environment variables
|
|
53
|
-
| Variable | Default | Purpose |
|
|
54
|
-
|----------|---------|---------|
|
|
55
|
-
| `FISHLADDER_API_KEY` | (none) | API key fallback — skips keytar |
|
|
56
|
-
| `FISHLADDER_API_URL` | `https://app.ladder.fish/api/v1` | API base URL |
|
|
57
|
-
| `FISHLADDER_AUTH_URL` | `https://app.ladder.fish` | Auth page base URL |
|
|
58
|
-
|
|
59
|
-
## API
|
|
60
|
-
|
|
61
|
-
Base URL: `https://app.ladder.fish/api/v1`
|
|
62
|
-
Auth: `Authorization: Bearer fl_<key>`
|
|
63
|
-
Rate limit: 120 req/hour
|
|
64
|
-
Errors: `{ "error": "message" }`
|
|
65
|
-
|
|
66
|
-
| CLI Command | HTTP | Endpoint |
|
|
67
|
-
|-------------|------|----------|
|
|
68
|
-
| `fish whoami` | GET | `/members` |
|
|
69
|
-
| `fish projects list` | GET | `/projects` |
|
|
70
|
-
| `fish tasks list` | GET | `/tasks?projectId=<id>` |
|
|
71
|
-
| `fish tasks create` | POST | `/tasks` (requires `projectId`, `title`) |
|
|
72
|
-
| `fish feedback list` | GET | `/feedback` |
|
|
73
|
-
| `fish feedback create` | POST | `/feedback` (requires `title`) |
|
|
74
|
-
| `fish hiring jobs` | GET | `/jobs` |
|
|
75
|
-
| `fish hiring candidates` | GET | `/candidates` |
|
|
76
|
-
| `fish hiring pipeline` | GET | `/applications?jobId=<id>` |
|
|
77
|
-
|
|
78
|
-
## Running / Developing
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
npm install # install dependencies
|
|
82
|
-
npm link # link globally so `fish` binary works
|
|
83
|
-
fish --help # verify it works
|
|
84
|
-
npm test # run tests (vitest)
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
To test against a local dev server:
|
|
88
|
-
```bash
|
|
89
|
-
FISHLADDER_API_URL=http://localhost:3000/api/v1 FISHLADDER_API_KEY=fl_<key> fish projects list
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
## Conventions
|
|
93
|
-
|
|
94
|
-
- All files are plain `.js` (ESM, no TypeScript, no build step)
|
|
95
|
-
- Every list command supports `--format json` for piping to `jq`
|
|
96
|
-
- Error handling: 401 → "Run: fish login", 429 → show retry-after, other errors → show error message. Never show stack traces to users.
|
|
97
|
-
- Token security: **never** write tokens to plaintext files. Keytar (system keychain) or env var only.
|
|
98
|
-
- Each command file exports a `registerXCommand(program)` function that adds itself to the Commander program
|
|
99
|
-
|
|
100
|
-
## v2 Commands (not yet built)
|
|
101
|
-
|
|
102
|
-
The spec (`docs/phase-5-cli-tool.md` in main repo) defines v2 commands to add after v1 is dogfooded:
|
|
103
|
-
- `fish tasks update/complete`
|
|
104
|
-
- `fish feedback analyze` (AI analysis, stdin support)
|
|
105
|
-
- `fish hiring move/hire/reject`
|
|
106
|
-
- Smart syntax: `fish tasks create "Fix bug" @sarah !high #backend due:friday`
|
|
107
|
-
|
|
108
|
-
## Status
|
|
109
|
-
|
|
110
|
-
- **v1 commands**: All built and tested end-to-end against live API
|
|
111
|
-
- **Tests**: vitest configured but no test files written yet
|
|
112
|
-
- **npm publish**: Not yet — dogfood internally first (per spec)
|
|
113
|
-
- **Track A (backend)**: Complete — `/cli-auth` page and `/api/cli-auth` endpoint exist in main app
|