fishladder 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/CLAUDE.md +113 -0
- package/bin/fish.js +25 -0
- package/package.json +27 -0
- package/src/api.js +41 -0
- package/src/auth.js +129 -0
- package/src/commands/feedback.js +69 -0
- package/src/commands/hiring.js +106 -0
- package/src/commands/login.js +15 -0
- package/src/commands/logout.js +15 -0
- package/src/commands/projects.js +42 -0
- package/src/commands/tasks.js +78 -0
- package/src/commands/whoami.js +22 -0
- package/src/format.js +15 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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
|
package/bin/fish.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { registerLoginCommand } from '../src/commands/login.js';
|
|
5
|
+
import { registerLogoutCommand } from '../src/commands/logout.js';
|
|
6
|
+
import { registerWhoamiCommand } from '../src/commands/whoami.js';
|
|
7
|
+
import { registerProjectsCommand } from '../src/commands/projects.js';
|
|
8
|
+
import { registerTasksCommand } from '../src/commands/tasks.js';
|
|
9
|
+
import { registerFeedbackCommand } from '../src/commands/feedback.js';
|
|
10
|
+
import { registerHiringCommand } from '../src/commands/hiring.js';
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('fish')
|
|
14
|
+
.description('Fishladder CLI — manage projects, tasks, feedback, and hiring')
|
|
15
|
+
.version('0.1.0');
|
|
16
|
+
|
|
17
|
+
registerLoginCommand(program);
|
|
18
|
+
registerLogoutCommand(program);
|
|
19
|
+
registerWhoamiCommand(program);
|
|
20
|
+
registerProjectsCommand(program);
|
|
21
|
+
registerTasksCommand(program);
|
|
22
|
+
registerFeedbackCommand(program);
|
|
23
|
+
registerHiringCommand(program);
|
|
24
|
+
|
|
25
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fishladder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fishladder CLI — manage projects, tasks, feedback, and hiring from your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fish": "./bin/fish.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["fishladder", "cli", "project-management"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"chalk": "^5.3.0",
|
|
19
|
+
"cli-table3": "^0.6.5",
|
|
20
|
+
"commander": "^12.1.0",
|
|
21
|
+
"keytar": "^7.9.0",
|
|
22
|
+
"open": "^10.1.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"vitest": "^2.1.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getToken } from './auth.js';
|
|
2
|
+
|
|
3
|
+
const BASE = process.env.FISHLADDER_API_URL ?? 'https://app.ladder.fish/api/v1';
|
|
4
|
+
|
|
5
|
+
export async function request(path, opts = {}) {
|
|
6
|
+
const token = await getToken();
|
|
7
|
+
|
|
8
|
+
if (!token) {
|
|
9
|
+
console.error('Not logged in. Run: fish login');
|
|
10
|
+
console.error('Or set FISHLADDER_API_KEY environment variable.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
15
|
+
...opts,
|
|
16
|
+
headers: {
|
|
17
|
+
'Authorization': `Bearer ${token}`,
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
...opts.headers,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (res.status === 401) {
|
|
24
|
+
console.error('Session expired or invalid token. Run: fish login');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (res.status === 429) {
|
|
29
|
+
const retryAfter = res.headers.get('Retry-After') ?? '60';
|
|
30
|
+
console.error(`Rate limit exceeded. Try again in ${retryAfter}s.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.json().catch(() => ({}));
|
|
36
|
+
console.error(`Error ${res.status}: ${body.error ?? 'Unknown error'}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return res.json();
|
|
41
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
|
|
6
|
+
const SERVICE = 'fishladder';
|
|
7
|
+
const ACCOUNT = 'token';
|
|
8
|
+
const AUTH_BASE = process.env.FISHLADDER_AUTH_URL ?? 'https://app.ladder.fish';
|
|
9
|
+
|
|
10
|
+
let keytar;
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import('keytar');
|
|
13
|
+
keytar = mod.default ?? mod;
|
|
14
|
+
} catch {
|
|
15
|
+
keytar = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function getKeytarOrFail() {
|
|
19
|
+
if (!keytar) {
|
|
20
|
+
console.error('Error: keytar is not available. Install it with: npm install -g keytar');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
return keytar;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPortAvailable(port) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const server = net.createServer();
|
|
29
|
+
server.once('error', () => resolve(false));
|
|
30
|
+
server.once('listening', () => {
|
|
31
|
+
server.close(() => resolve(true));
|
|
32
|
+
});
|
|
33
|
+
server.listen(port, '127.0.0.1');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function findAvailablePort(start, end) {
|
|
38
|
+
for (let port = start; port <= end; port++) {
|
|
39
|
+
if (await isPortAvailable(port)) return port;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function login() {
|
|
45
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
46
|
+
|
|
47
|
+
const port = await findAvailablePort(9876, 9885);
|
|
48
|
+
if (!port) {
|
|
49
|
+
console.error('Error: Could not find an available port (9876-9885).');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const token = await new Promise((resolve, reject) => {
|
|
54
|
+
const server = http.createServer((req, res) => {
|
|
55
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
56
|
+
|
|
57
|
+
if (url.pathname !== '/callback') {
|
|
58
|
+
res.writeHead(404);
|
|
59
|
+
res.end('Not found');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const receivedState = url.searchParams.get('state');
|
|
64
|
+
const receivedToken = url.searchParams.get('token');
|
|
65
|
+
|
|
66
|
+
if (receivedState !== state) {
|
|
67
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
68
|
+
res.end('<html><body><h2>Error: state mismatch</h2><p>Please try again.</p></body></html>');
|
|
69
|
+
server.close();
|
|
70
|
+
reject(new Error('State mismatch — possible CSRF attempt'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!receivedToken) {
|
|
75
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
76
|
+
res.end('<html><body><h2>Error: no token received</h2></body></html>');
|
|
77
|
+
server.close();
|
|
78
|
+
reject(new Error('No token received'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
83
|
+
res.end('<html><body><h2>Authenticated!</h2><p>You can close this tab and return to your terminal.</p></body></html>');
|
|
84
|
+
server.close();
|
|
85
|
+
resolve(receivedToken);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
server.listen(port, '127.0.0.1', () => {
|
|
89
|
+
const authUrl = `${AUTH_BASE}/cli-auth?state=${state}&port=${port}`;
|
|
90
|
+
console.log('Opening browser to authenticate...');
|
|
91
|
+
console.log(`If the browser doesn't open, visit: ${authUrl}`);
|
|
92
|
+
open(authUrl);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
server.on('error', (err) => {
|
|
96
|
+
reject(new Error(`Local server error: ${err.message}`));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 2 minute timeout
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
server.close();
|
|
102
|
+
reject(new Error('Login timed out after 2 minutes. Please try again.'));
|
|
103
|
+
}, 120_000);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const kt = await getKeytarOrFail();
|
|
107
|
+
await kt.setPassword(SERVICE, ACCOUNT, token);
|
|
108
|
+
console.log('Logged in successfully.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function logout() {
|
|
112
|
+
const kt = await getKeytarOrFail();
|
|
113
|
+
const existed = await kt.getPassword(SERVICE, ACCOUNT);
|
|
114
|
+
await kt.deletePassword(SERVICE, ACCOUNT);
|
|
115
|
+
if (existed) {
|
|
116
|
+
console.log('Logged out.');
|
|
117
|
+
} else {
|
|
118
|
+
console.log('No active session found.');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function getToken() {
|
|
123
|
+
// Environment variable fallback (for development / CI)
|
|
124
|
+
const envKey = process.env.FISHLADDER_API_KEY;
|
|
125
|
+
if (envKey) return envKey;
|
|
126
|
+
|
|
127
|
+
if (!keytar) return null;
|
|
128
|
+
return keytar.getPassword(SERVICE, ACCOUNT);
|
|
129
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { request } from '../api.js';
|
|
2
|
+
import { formatTable, formatJson } from '../format.js';
|
|
3
|
+
|
|
4
|
+
export function registerFeedbackCommand(program) {
|
|
5
|
+
const feedback = program
|
|
6
|
+
.command('feedback')
|
|
7
|
+
.description('Manage feedback entries');
|
|
8
|
+
|
|
9
|
+
feedback
|
|
10
|
+
.command('list')
|
|
11
|
+
.description('List all feedback')
|
|
12
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const data = await request('/feedback');
|
|
15
|
+
|
|
16
|
+
if (opts.format === 'json') {
|
|
17
|
+
formatJson(data);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (data.length === 0) {
|
|
22
|
+
console.log('No feedback found.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
formatTable(
|
|
27
|
+
['ID', 'Title', 'Impact', 'Status', 'Company'],
|
|
28
|
+
data.map(f => [
|
|
29
|
+
f.id.slice(0, 8),
|
|
30
|
+
truncate(f.title, 40),
|
|
31
|
+
f.impact ?? '-',
|
|
32
|
+
f.status ?? '-',
|
|
33
|
+
truncate(f.company, 20),
|
|
34
|
+
])
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
feedback
|
|
39
|
+
.command('create <title>')
|
|
40
|
+
.description('Create a new feedback entry')
|
|
41
|
+
.option('--details <text>', 'Feedback details')
|
|
42
|
+
.option('--impact <level>', 'Impact: low, medium, high', 'medium')
|
|
43
|
+
.option('--company <name>', 'Company name')
|
|
44
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
45
|
+
.action(async (title, opts) => {
|
|
46
|
+
const body = { title };
|
|
47
|
+
|
|
48
|
+
if (opts.details) body.details = opts.details;
|
|
49
|
+
if (opts.impact) body.impact = opts.impact;
|
|
50
|
+
if (opts.company) body.company = opts.company;
|
|
51
|
+
|
|
52
|
+
const entry = await request('/feedback', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (opts.format === 'json') {
|
|
58
|
+
formatJson(entry);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`Feedback created: ${entry.title} (${entry.id.slice(0, 8)})`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncate(str, len) {
|
|
67
|
+
if (!str) return '-';
|
|
68
|
+
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { request } from '../api.js';
|
|
2
|
+
import { formatTable, formatJson } from '../format.js';
|
|
3
|
+
|
|
4
|
+
export function registerHiringCommand(program) {
|
|
5
|
+
const hiring = program
|
|
6
|
+
.command('hiring')
|
|
7
|
+
.description('Manage hiring / ATS');
|
|
8
|
+
|
|
9
|
+
hiring
|
|
10
|
+
.command('jobs')
|
|
11
|
+
.description('List open jobs')
|
|
12
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const data = await request('/jobs');
|
|
15
|
+
|
|
16
|
+
if (opts.format === 'json') {
|
|
17
|
+
formatJson(data);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (data.length === 0) {
|
|
22
|
+
console.log('No jobs found.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
formatTable(
|
|
27
|
+
['ID', 'Title', 'Department', 'Location', 'Status'],
|
|
28
|
+
data.map(j => [
|
|
29
|
+
j.id.slice(0, 8),
|
|
30
|
+
truncate(j.title, 35),
|
|
31
|
+
truncate(j.department, 15),
|
|
32
|
+
truncate(j.location, 15),
|
|
33
|
+
j.status ?? '-',
|
|
34
|
+
])
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
hiring
|
|
39
|
+
.command('candidates')
|
|
40
|
+
.description('List all candidates')
|
|
41
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
const data = await request('/candidates');
|
|
44
|
+
|
|
45
|
+
if (opts.format === 'json') {
|
|
46
|
+
formatJson(data);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (data.length === 0) {
|
|
51
|
+
console.log('No candidates found.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
formatTable(
|
|
56
|
+
['ID', 'Name', 'Email', 'Source', 'Location'],
|
|
57
|
+
data.map(c => [
|
|
58
|
+
c.id.slice(0, 8),
|
|
59
|
+
truncate(`${c.firstName} ${c.lastName}`, 25),
|
|
60
|
+
truncate(c.email, 25),
|
|
61
|
+
truncate(c.source, 12),
|
|
62
|
+
truncate(c.location, 15),
|
|
63
|
+
])
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
hiring
|
|
68
|
+
.command('pipeline')
|
|
69
|
+
.description('List applications (hiring pipeline)')
|
|
70
|
+
.option('--job <id>', 'Filter by job ID')
|
|
71
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
72
|
+
.action(async (opts) => {
|
|
73
|
+
const params = new URLSearchParams();
|
|
74
|
+
if (opts.job) params.set('jobId', opts.job);
|
|
75
|
+
|
|
76
|
+
const qs = params.toString();
|
|
77
|
+
const data = await request(`/applications${qs ? `?${qs}` : ''}`);
|
|
78
|
+
|
|
79
|
+
if (opts.format === 'json') {
|
|
80
|
+
formatJson(data);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (data.length === 0) {
|
|
85
|
+
console.log('No applications found.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
formatTable(
|
|
90
|
+
['ID', 'Job', 'Candidate', 'Stage', 'Status', 'Applied'],
|
|
91
|
+
data.map(a => [
|
|
92
|
+
a.id.slice(0, 8),
|
|
93
|
+
a.jobId.slice(0, 8),
|
|
94
|
+
a.candidateId.slice(0, 8),
|
|
95
|
+
a.stageId ? a.stageId.slice(0, 8) : '-',
|
|
96
|
+
a.status ?? '-',
|
|
97
|
+
a.appliedAt?.split('T')[0] ?? '-',
|
|
98
|
+
])
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function truncate(str, len) {
|
|
104
|
+
if (!str) return '-';
|
|
105
|
+
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { login } from '../auth.js';
|
|
2
|
+
|
|
3
|
+
export function registerLoginCommand(program) {
|
|
4
|
+
program
|
|
5
|
+
.command('login')
|
|
6
|
+
.description('Authenticate with Fishladder via browser')
|
|
7
|
+
.action(async () => {
|
|
8
|
+
try {
|
|
9
|
+
await login();
|
|
10
|
+
} catch (err) {
|
|
11
|
+
console.error(`Login failed: ${err.message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { logout } from '../auth.js';
|
|
2
|
+
|
|
3
|
+
export function registerLogoutCommand(program) {
|
|
4
|
+
program
|
|
5
|
+
.command('logout')
|
|
6
|
+
.description('Remove stored authentication token')
|
|
7
|
+
.action(async () => {
|
|
8
|
+
try {
|
|
9
|
+
await logout();
|
|
10
|
+
} catch (err) {
|
|
11
|
+
console.error(`Logout failed: ${err.message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { request } from '../api.js';
|
|
2
|
+
import { formatTable, formatJson } from '../format.js';
|
|
3
|
+
|
|
4
|
+
export function registerProjectsCommand(program) {
|
|
5
|
+
const projects = program
|
|
6
|
+
.command('projects')
|
|
7
|
+
.description('Manage roadmap projects');
|
|
8
|
+
|
|
9
|
+
projects
|
|
10
|
+
.command('list')
|
|
11
|
+
.description('List all projects')
|
|
12
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const data = await request('/projects');
|
|
15
|
+
|
|
16
|
+
if (opts.format === 'json') {
|
|
17
|
+
formatJson(data);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (data.length === 0) {
|
|
22
|
+
console.log('No projects found.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
formatTable(
|
|
27
|
+
['ID', 'Title', 'Status', 'Start', 'Target'],
|
|
28
|
+
data.map(p => [
|
|
29
|
+
p.id.slice(0, 8),
|
|
30
|
+
truncate(p.title, 40),
|
|
31
|
+
p.status ?? '-',
|
|
32
|
+
p.startDate ?? '-',
|
|
33
|
+
p.targetDate ?? '-',
|
|
34
|
+
])
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function truncate(str, len) {
|
|
40
|
+
if (!str) return '-';
|
|
41
|
+
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { request } from '../api.js';
|
|
2
|
+
import { formatTable, formatJson } from '../format.js';
|
|
3
|
+
|
|
4
|
+
export function registerTasksCommand(program) {
|
|
5
|
+
const tasks = program
|
|
6
|
+
.command('tasks')
|
|
7
|
+
.description('Manage tracker tasks');
|
|
8
|
+
|
|
9
|
+
tasks
|
|
10
|
+
.command('list')
|
|
11
|
+
.description('List tasks')
|
|
12
|
+
.option('--project <id>', 'Filter by project ID')
|
|
13
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
const params = new URLSearchParams();
|
|
16
|
+
if (opts.project) params.set('projectId', opts.project);
|
|
17
|
+
|
|
18
|
+
const qs = params.toString();
|
|
19
|
+
const data = await request(`/tasks${qs ? `?${qs}` : ''}`);
|
|
20
|
+
|
|
21
|
+
if (opts.format === 'json') {
|
|
22
|
+
formatJson(data);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (data.length === 0) {
|
|
27
|
+
console.log('No tasks found.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
formatTable(
|
|
32
|
+
['ID', 'Title', 'Priority', 'Due', 'Assignee'],
|
|
33
|
+
data.map(t => [
|
|
34
|
+
t.id.slice(0, 8),
|
|
35
|
+
truncate(t.title, 40),
|
|
36
|
+
t.priority ?? 'none',
|
|
37
|
+
t.dueDate ?? '-',
|
|
38
|
+
t.assigneeId ? t.assigneeId.slice(0, 8) : '-',
|
|
39
|
+
])
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
tasks
|
|
44
|
+
.command('create <title>')
|
|
45
|
+
.description('Create a new task')
|
|
46
|
+
.requiredOption('--project <id>', 'Project ID (required)')
|
|
47
|
+
.option('--assign <userId>', 'Assignee user ID')
|
|
48
|
+
.option('--priority <level>', 'Priority: none, low, medium, high, urgent', 'none')
|
|
49
|
+
.option('--due <date>', 'Due date (YYYY-MM-DD)')
|
|
50
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
51
|
+
.action(async (title, opts) => {
|
|
52
|
+
const body = {
|
|
53
|
+
title,
|
|
54
|
+
projectId: opts.project,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (opts.assign) body.assigneeId = opts.assign;
|
|
58
|
+
if (opts.priority) body.priority = opts.priority;
|
|
59
|
+
if (opts.due) body.dueDate = opts.due;
|
|
60
|
+
|
|
61
|
+
const task = await request('/tasks', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (opts.format === 'json') {
|
|
67
|
+
formatJson(task);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(`Task created: ${task.title} (${task.id.slice(0, 8)})`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function truncate(str, len) {
|
|
76
|
+
if (!str) return '-';
|
|
77
|
+
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
78
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { request } from '../api.js';
|
|
2
|
+
import { formatTable, formatJson } from '../format.js';
|
|
3
|
+
|
|
4
|
+
export function registerWhoamiCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('whoami')
|
|
7
|
+
.description('Show authenticated user info')
|
|
8
|
+
.option('--format <format>', 'Output format: table or json', 'table')
|
|
9
|
+
.action(async (opts) => {
|
|
10
|
+
const user = await request('/me');
|
|
11
|
+
|
|
12
|
+
if (opts.format === 'json') {
|
|
13
|
+
formatJson(user);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
formatTable(
|
|
18
|
+
['Name', 'Email', 'Role'],
|
|
19
|
+
[[user.name ?? '-', user.email ?? '-', user.role ?? '-']]
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Table from 'cli-table3';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export function formatTable(headers, rows) {
|
|
5
|
+
const table = new Table({
|
|
6
|
+
head: headers.map(h => chalk.dim(h)),
|
|
7
|
+
style: { head: [], border: [] },
|
|
8
|
+
});
|
|
9
|
+
rows.forEach(row => table.push(row));
|
|
10
|
+
console.log(table.toString());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatJson(data) {
|
|
14
|
+
console.log(JSON.stringify(data, null, 2));
|
|
15
|
+
}
|