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 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.1.0');
15
+ .version('0.2.0');
16
16
 
17
17
  registerLoginCommand(program);
18
18
  registerLogoutCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fishladder",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Fishladder CLI — manage projects, tasks, feedback, and hiring from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) {
@@ -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) {
@@ -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
- .action(async (title, opts) => {
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 (opts.priority) body.priority = opts.priority;
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