1dr-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/package.json +16 -0
- package/src/auth.ts +153 -0
- package/src/client.ts +43 -0
- package/src/commands/config.ts +18 -0
- package/src/commands/login.ts +10 -0
- package/src/commands/logout.ts +5 -0
- package/src/commands/mode.ts +13 -0
- package/src/commands/transcripts.ts +283 -0
- package/src/commands/whoami.ts +36 -0
- package/src/config.ts +58 -0
- package/src/index.ts +102 -0
- package/tsconfig.json +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "1dr-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"1dr": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "bun run src/index.ts",
|
|
10
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
11
|
+
"compile": "bun build ./src/index.ts --compile --outfile 1dr"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"commander": "^13.1.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { CREDENTIALS_PATH, ensureConfigDir, loadConfig, type Credentials } from './config';
|
|
2
|
+
|
|
3
|
+
const CLIENT_ID = '1dr-cli';
|
|
4
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
export async function login(): Promise<void> {
|
|
7
|
+
const config = await loadConfig();
|
|
8
|
+
const base = config.baseUrl;
|
|
9
|
+
|
|
10
|
+
// Step 1: Request device + user code
|
|
11
|
+
const codeRes = await fetch(`${base}/api/auth/device/code`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
body: JSON.stringify({ client_id: CLIENT_ID })
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (!codeRes.ok) {
|
|
18
|
+
const err = await codeRes.json().catch(() => null);
|
|
19
|
+
throw new Error(`Failed to start device flow: ${err?.error_description || codeRes.statusText}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const codeData = (await codeRes.json()) as {
|
|
23
|
+
device_code: string;
|
|
24
|
+
user_code: string;
|
|
25
|
+
verification_uri: string;
|
|
26
|
+
verification_uri_complete: string;
|
|
27
|
+
expires_in: number;
|
|
28
|
+
interval: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(` Open this URL in your browser:`);
|
|
33
|
+
console.log(` \x1b[1m\x1b[36m${codeData.verification_uri_complete}\x1b[0m`);
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(` Or go to \x1b[36m${codeData.verification_uri}\x1b[0m and enter code:`);
|
|
36
|
+
console.log(` \x1b[1m\x1b[33m${codeData.user_code}\x1b[0m`);
|
|
37
|
+
console.log();
|
|
38
|
+
console.log(` Waiting for authorization...`);
|
|
39
|
+
|
|
40
|
+
// Step 2: Poll for token
|
|
41
|
+
const deadline = Date.now() + codeData.expires_in * 1000;
|
|
42
|
+
const interval = Math.max((codeData.interval || 5) * 1000, POLL_INTERVAL_MS);
|
|
43
|
+
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
await sleep(interval);
|
|
46
|
+
|
|
47
|
+
const tokenRes = await fetch(`${base}/api/auth/device/token`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
52
|
+
device_code: codeData.device_code,
|
|
53
|
+
client_id: CLIENT_ID
|
|
54
|
+
})
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (tokenRes.ok) {
|
|
58
|
+
const tokenData = (await tokenRes.json()) as {
|
|
59
|
+
access_token: string;
|
|
60
|
+
token_type: string;
|
|
61
|
+
expires_in: number;
|
|
62
|
+
scope: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await saveCredentials({
|
|
66
|
+
accessToken: tokenData.access_token,
|
|
67
|
+
expiresIn: tokenData.expires_in,
|
|
68
|
+
obtainedAt: Date.now()
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
console.log(` \x1b[32mAuthenticated successfully!\x1b[0m`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const err = (await tokenRes.json().catch(() => null)) as {
|
|
76
|
+
error?: string;
|
|
77
|
+
error_description?: string;
|
|
78
|
+
} | null;
|
|
79
|
+
|
|
80
|
+
if (err?.error === 'authorization_pending') {
|
|
81
|
+
continue; // still waiting
|
|
82
|
+
}
|
|
83
|
+
if (err?.error === 'slow_down') {
|
|
84
|
+
await sleep(5000); // back off
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (err?.error === 'access_denied') {
|
|
88
|
+
throw new Error('Authorization denied by user');
|
|
89
|
+
}
|
|
90
|
+
if (err?.error === 'expired_token') {
|
|
91
|
+
throw new Error('Device code expired. Please try again.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(`Token error: ${err?.error_description || err?.error || 'unknown'}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error('Device code expired. Please try again.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function logout(): Promise<void> {
|
|
101
|
+
const { unlink } = await import('fs/promises');
|
|
102
|
+
try {
|
|
103
|
+
await unlink(CREDENTIALS_PATH);
|
|
104
|
+
console.log('Logged out successfully.');
|
|
105
|
+
} catch {
|
|
106
|
+
console.log('Not currently logged in.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getToken(): Promise<string> {
|
|
111
|
+
const creds = await loadCredentials();
|
|
112
|
+
if (!creds) {
|
|
113
|
+
throw new Error('Not logged in. Run `1dr login` first.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check if expired (with 60s buffer)
|
|
117
|
+
const expiresAt = creds.obtainedAt + creds.expiresIn * 1000;
|
|
118
|
+
if (Date.now() > expiresAt - 60_000) {
|
|
119
|
+
throw new Error('Session expired. Run `1dr login` to re-authenticate.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return creds.accessToken;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loadCredentials(): Promise<Credentials | null> {
|
|
126
|
+
try {
|
|
127
|
+
const raw = await Bun.file(CREDENTIALS_PATH).text();
|
|
128
|
+
return JSON.parse(raw) as Credentials;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Bump obtainedAt to now — keeps local expiry in sync with
|
|
136
|
+
* the server-side session extension that happens on every request.
|
|
137
|
+
*/
|
|
138
|
+
export async function refreshCredentials(): Promise<void> {
|
|
139
|
+
const creds = await loadCredentials();
|
|
140
|
+
if (creds) {
|
|
141
|
+
creds.obtainedAt = Date.now();
|
|
142
|
+
await saveCredentials(creds);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function saveCredentials(creds: Credentials): Promise<void> {
|
|
147
|
+
await ensureConfigDir();
|
|
148
|
+
await Bun.write(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + '\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sleep(ms: number): Promise<void> {
|
|
152
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
153
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { getToken, refreshCredentials } from './auth';
|
|
2
|
+
import { loadConfig } from './config';
|
|
3
|
+
|
|
4
|
+
type ApiResponse<T> = { data: T } | { error: { code: string; message: string; details?: unknown } };
|
|
5
|
+
|
|
6
|
+
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
|
7
|
+
const config = await loadConfig();
|
|
8
|
+
const token = await getToken();
|
|
9
|
+
|
|
10
|
+
const url = `${config.baseUrl}${path}`;
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
...options,
|
|
13
|
+
headers: {
|
|
14
|
+
'Authorization': `Bearer ${token}`,
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
...options?.headers
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (res.status === 401) {
|
|
21
|
+
throw new Error('Session expired. Run `1dr login` to re-authenticate.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Server extends session on every authenticated request,
|
|
25
|
+
// so bump local expiry to stay in sync
|
|
26
|
+
await refreshCredentials();
|
|
27
|
+
|
|
28
|
+
const body = (await res.json()) as ApiResponse<T>;
|
|
29
|
+
|
|
30
|
+
if ('error' in body) {
|
|
31
|
+
throw new Error(`${body.error.code}: ${body.error.message}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return body.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
|
38
|
+
const entries = Object.entries(params).filter(
|
|
39
|
+
(entry): entry is [string, string | number] => entry[1] !== undefined
|
|
40
|
+
);
|
|
41
|
+
if (entries.length === 0) return '';
|
|
42
|
+
return '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString();
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from '../config';
|
|
2
|
+
|
|
3
|
+
export async function configCommand(opts: { baseUrl?: string; json?: boolean }) {
|
|
4
|
+
if (opts.baseUrl) {
|
|
5
|
+
await saveConfig({ baseUrl: opts.baseUrl });
|
|
6
|
+
console.log(`Base URL set to: ${opts.baseUrl}`);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const config = await loadConfig();
|
|
11
|
+
|
|
12
|
+
if (opts.json) {
|
|
13
|
+
console.log(JSON.stringify(config, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(` Base URL: ${config.baseUrl}`);
|
|
18
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { loadConfig, setMode } from '../config';
|
|
2
|
+
|
|
3
|
+
export async function devCommand() {
|
|
4
|
+
await setMode(true);
|
|
5
|
+
const config = await loadConfig();
|
|
6
|
+
console.log(` \x1b[33m[DEV]\x1b[0m Switched to dev mode → ${config.baseUrl}`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function prodCommand() {
|
|
10
|
+
await setMode(false);
|
|
11
|
+
const config = await loadConfig();
|
|
12
|
+
console.log(` Switched to prod mode → ${config.baseUrl}`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { api, buildQuery } from '../client';
|
|
2
|
+
|
|
3
|
+
type CompanySearchResult = {
|
|
4
|
+
exchangeTicker: string | null;
|
|
5
|
+
ticker: string | null;
|
|
6
|
+
exchange: string | null;
|
|
7
|
+
yahooTicker: string | null;
|
|
8
|
+
name: string | null;
|
|
9
|
+
sector: string | null;
|
|
10
|
+
industry: string | null;
|
|
11
|
+
country: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type TranscriptListItem = {
|
|
15
|
+
id: number;
|
|
16
|
+
headline: string | null;
|
|
17
|
+
date: string | null;
|
|
18
|
+
type: string | null;
|
|
19
|
+
year: number | null;
|
|
20
|
+
ticker: string | null;
|
|
21
|
+
exchange: string | null;
|
|
22
|
+
exchangeTicker: string | null;
|
|
23
|
+
yahooTicker: string | null;
|
|
24
|
+
companyName: string | null;
|
|
25
|
+
sector: string | null;
|
|
26
|
+
industry: string | null;
|
|
27
|
+
country: string | null;
|
|
28
|
+
usdmarketcap: number | null;
|
|
29
|
+
snippets: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type TranscriptListResponse = {
|
|
33
|
+
items: TranscriptListItem[];
|
|
34
|
+
cursor: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type TranscriptDetail = {
|
|
38
|
+
id: number;
|
|
39
|
+
headline: string | null;
|
|
40
|
+
date: string | null;
|
|
41
|
+
type: string | null;
|
|
42
|
+
year: number | null;
|
|
43
|
+
ticker: string | null;
|
|
44
|
+
exchange: string | null;
|
|
45
|
+
exchangeTicker: string | null;
|
|
46
|
+
yahooTicker: string | null;
|
|
47
|
+
companyName: string | null;
|
|
48
|
+
components: Array<{
|
|
49
|
+
order: number;
|
|
50
|
+
text: string | null;
|
|
51
|
+
speaker: string | null;
|
|
52
|
+
speakerTypeId: number | null;
|
|
53
|
+
componentTypeId: number | null;
|
|
54
|
+
}>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type CompanyTranscriptItem = {
|
|
58
|
+
id: number;
|
|
59
|
+
headline: string | null;
|
|
60
|
+
date: string | null;
|
|
61
|
+
type: string | null;
|
|
62
|
+
year: number | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type CompanyTranscriptsResponse = {
|
|
66
|
+
company: CompanySearchResult | null;
|
|
67
|
+
items: CompanyTranscriptItem[];
|
|
68
|
+
cursor: string | null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type LastUpdatedResponse = {
|
|
72
|
+
lastUpdated: string | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ── Company search ──────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export async function companySearchCommand(query: string, opts: { json?: boolean }) {
|
|
78
|
+
try {
|
|
79
|
+
const q = buildQuery({ q: query });
|
|
80
|
+
const results = await api<CompanySearchResult[]>(`/api/v1/transcripts/company-search${q}`);
|
|
81
|
+
|
|
82
|
+
if (opts.json) {
|
|
83
|
+
console.log(JSON.stringify(results, null, 2));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (results.length === 0) {
|
|
88
|
+
console.log('No companies found.');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const c of results) {
|
|
93
|
+
console.log(
|
|
94
|
+
` \x1b[1m${c.exchangeTicker || c.ticker || '???'}\x1b[0m ${c.name || ''} \x1b[2m${c.sector || ''} | ${c.country || ''}\x1b[0m`
|
|
95
|
+
);
|
|
96
|
+
if (c.yahooTicker && c.yahooTicker !== c.ticker) {
|
|
97
|
+
console.log(` yahoo: ${c.yahooTicker}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error((err as Error).message);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── List transcripts ────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export async function listCommand(opts: {
|
|
109
|
+
limit?: string;
|
|
110
|
+
cursor?: string;
|
|
111
|
+
types?: string;
|
|
112
|
+
year?: string;
|
|
113
|
+
dateFrom?: string;
|
|
114
|
+
dateTo?: string;
|
|
115
|
+
search?: string;
|
|
116
|
+
yahooTicker?: string;
|
|
117
|
+
json?: boolean;
|
|
118
|
+
}) {
|
|
119
|
+
try {
|
|
120
|
+
const q = buildQuery({
|
|
121
|
+
limit: opts.limit,
|
|
122
|
+
cursor: opts.cursor,
|
|
123
|
+
types: opts.types,
|
|
124
|
+
year: opts.year,
|
|
125
|
+
dateFrom: opts.dateFrom,
|
|
126
|
+
dateTo: opts.dateTo,
|
|
127
|
+
search: opts.search,
|
|
128
|
+
yahooTicker: opts.yahooTicker
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = await api<TranscriptListResponse>(`/api/v1/transcripts${q}`);
|
|
132
|
+
|
|
133
|
+
if (opts.json) {
|
|
134
|
+
console.log(JSON.stringify(result, null, 2));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (result.items.length === 0) {
|
|
139
|
+
console.log('No transcripts found.');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const t of result.items) {
|
|
144
|
+
const date = t.date ? new Date(t.date).toLocaleDateString() : 'n/a';
|
|
145
|
+
console.log(
|
|
146
|
+
` \x1b[1m${t.id}\x1b[0m ${date} \x1b[36m${t.exchangeTicker || t.ticker || '???'}\x1b[0m ${t.headline || ''}`
|
|
147
|
+
);
|
|
148
|
+
if (t.snippets.length > 0) {
|
|
149
|
+
for (const s of t.snippets.slice(0, 2)) {
|
|
150
|
+
const clean = s.replace(/<\/?mark>/g, '');
|
|
151
|
+
console.log(` \x1b[2m${clean.slice(0, 120)}...\x1b[0m`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(`\n ${result.items.length} result(s)`);
|
|
157
|
+
if (result.cursor) {
|
|
158
|
+
console.log(` \x1b[2mNext page: --cursor ${result.cursor}\x1b[0m`);
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error((err as Error).message);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Get single transcript ───────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export async function getCommand(id: string, opts: { json?: boolean }) {
|
|
169
|
+
try {
|
|
170
|
+
const transcript = await api<TranscriptDetail>(`/api/v1/transcripts/${id}`);
|
|
171
|
+
|
|
172
|
+
if (opts.json) {
|
|
173
|
+
console.log(JSON.stringify(transcript, null, 2));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`\n \x1b[1m${transcript.headline}\x1b[0m`);
|
|
178
|
+
console.log(
|
|
179
|
+
` ${transcript.exchangeTicker || transcript.ticker} | ${transcript.type} | ${transcript.date ? new Date(transcript.date).toLocaleDateString() : ''}\n`
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
for (const c of transcript.components) {
|
|
183
|
+
if (c.speaker) {
|
|
184
|
+
console.log(`\x1b[1m\x1b[33m${c.speaker}:\x1b[0m`);
|
|
185
|
+
}
|
|
186
|
+
if (c.text) {
|
|
187
|
+
console.log(c.text);
|
|
188
|
+
console.log();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error((err as Error).message);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Company transcripts ─────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export async function companyCommand(
|
|
200
|
+
ticker: string,
|
|
201
|
+
opts: {
|
|
202
|
+
limit?: string;
|
|
203
|
+
cursor?: string;
|
|
204
|
+
timeRange?: string;
|
|
205
|
+
types?: string;
|
|
206
|
+
year?: string;
|
|
207
|
+
dateFrom?: string;
|
|
208
|
+
dateTo?: string;
|
|
209
|
+
json?: boolean;
|
|
210
|
+
}
|
|
211
|
+
) {
|
|
212
|
+
try {
|
|
213
|
+
const q = buildQuery({
|
|
214
|
+
limit: opts.limit,
|
|
215
|
+
cursor: opts.cursor,
|
|
216
|
+
timeRange: opts.timeRange,
|
|
217
|
+
types: opts.types,
|
|
218
|
+
year: opts.year,
|
|
219
|
+
dateFrom: opts.dateFrom,
|
|
220
|
+
dateTo: opts.dateTo
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const result = await api<CompanyTranscriptsResponse>(
|
|
224
|
+
`/api/v1/transcripts/company/${encodeURIComponent(ticker)}${q}`
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (opts.json) {
|
|
228
|
+
console.log(JSON.stringify(result, null, 2));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!result.company) {
|
|
233
|
+
console.log(`No company found for ticker: ${ticker}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(
|
|
238
|
+
`\n \x1b[1m${result.company.name}\x1b[0m (${result.company.exchangeTicker || result.company.ticker})\n`
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (result.items.length === 0) {
|
|
242
|
+
console.log(' No transcripts found.');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const t of result.items) {
|
|
247
|
+
const date = t.date ? new Date(t.date).toLocaleDateString() : 'n/a';
|
|
248
|
+
console.log(
|
|
249
|
+
` \x1b[1m${t.id}\x1b[0m ${date} ${t.type || ''} ${t.year || ''} ${t.headline || ''}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(`\n ${result.items.length} transcript(s)`);
|
|
254
|
+
if (result.cursor) {
|
|
255
|
+
console.log(` \x1b[2mNext page: --cursor ${result.cursor}\x1b[0m`);
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error((err as Error).message);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Last updated ────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
export async function lastUpdatedCommand(opts: { json?: boolean }) {
|
|
266
|
+
try {
|
|
267
|
+
const result = await api<LastUpdatedResponse>(`/api/v1/transcripts/last-updated`);
|
|
268
|
+
|
|
269
|
+
if (opts.json) {
|
|
270
|
+
console.log(JSON.stringify(result, null, 2));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (result.lastUpdated) {
|
|
275
|
+
console.log(` Last updated: ${new Date(result.lastUpdated).toLocaleString()}`);
|
|
276
|
+
} else {
|
|
277
|
+
console.log(' No transcripts in database.');
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error((err as Error).message);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getToken } from '../auth';
|
|
2
|
+
import { loadConfig } from '../config';
|
|
3
|
+
|
|
4
|
+
export async function whoamiCommand(opts: { json?: boolean }) {
|
|
5
|
+
try {
|
|
6
|
+
const config = await loadConfig();
|
|
7
|
+
const token = await getToken();
|
|
8
|
+
|
|
9
|
+
const res = await fetch(`${config.baseUrl}/api/auth/get-session`, {
|
|
10
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
throw new Error('Failed to get session. Try `1dr login` again.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const data = (await res.json()) as {
|
|
18
|
+
user: { id: string; name: string; email: string; level: number; username?: string };
|
|
19
|
+
session: { id: string; expiresAt: string };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
console.log(JSON.stringify(data, null, 2));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log(` Name: ${data.user.name}`);
|
|
28
|
+
console.log(` Email: ${data.user.email}`);
|
|
29
|
+
console.log(` Username: ${data.user.username || '(not set)'}`);
|
|
30
|
+
console.log(` Level: ${data.user.level}`);
|
|
31
|
+
console.log(` Expires: ${new Date(data.session.expiresAt).toLocaleString()}`);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error((err as Error).message);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_DIR = join(homedir(), '.config', '1dr');
|
|
5
|
+
export const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials.json');
|
|
6
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export type Config = {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
dev?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Credentials = {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
expiresIn: number;
|
|
16
|
+
obtainedAt: number; // epoch ms
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const PROD_URL = 'https://firstdraftresearch.com';
|
|
20
|
+
const DEV_URL = 'http://localhost:5173';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONFIG: Config = {
|
|
23
|
+
baseUrl: PROD_URL,
|
|
24
|
+
dev: false
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function ensureConfigDir() {
|
|
28
|
+
const { mkdir } = await import('fs/promises');
|
|
29
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadConfig(): Promise<Config> {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await Bun.file(CONFIG_PATH).text();
|
|
35
|
+
const stored = JSON.parse(raw);
|
|
36
|
+
const config = { ...DEFAULT_CONFIG, ...stored };
|
|
37
|
+
// Resolve baseUrl from dev flag unless explicitly overridden
|
|
38
|
+
if (!stored.baseUrl) {
|
|
39
|
+
config.baseUrl = config.dev ? DEV_URL : PROD_URL;
|
|
40
|
+
}
|
|
41
|
+
return config;
|
|
42
|
+
} catch {
|
|
43
|
+
return DEFAULT_CONFIG;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function saveConfig(config: Partial<Config>) {
|
|
48
|
+
await ensureConfigDir();
|
|
49
|
+
const existing = await loadConfig();
|
|
50
|
+
const merged = { ...existing, ...config };
|
|
51
|
+
await Bun.write(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Switch dev/prod mode — clears any manual baseUrl override */
|
|
55
|
+
export async function setMode(dev: boolean) {
|
|
56
|
+
await ensureConfigDir();
|
|
57
|
+
await Bun.write(CONFIG_PATH, JSON.stringify({ dev }, null, 2) + '\n');
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { loginCommand } from './commands/login';
|
|
4
|
+
import { logoutCommand } from './commands/logout';
|
|
5
|
+
import { whoamiCommand } from './commands/whoami';
|
|
6
|
+
import {
|
|
7
|
+
companySearchCommand,
|
|
8
|
+
listCommand,
|
|
9
|
+
getCommand,
|
|
10
|
+
companyCommand,
|
|
11
|
+
lastUpdatedCommand
|
|
12
|
+
} from './commands/transcripts';
|
|
13
|
+
import { configCommand } from './commands/config';
|
|
14
|
+
import { devCommand, prodCommand } from './commands/mode';
|
|
15
|
+
import { loadConfig } from './config';
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
|
|
19
|
+
program.name('1dr').description('First Draft Research CLI').version('0.1.0');
|
|
20
|
+
|
|
21
|
+
// Show [DEV] indicator before every command when in dev mode
|
|
22
|
+
program.hook('preAction', async (_thisCmd, actionCmd) => {
|
|
23
|
+
if (['dev', 'prod'].includes(actionCmd.name())) return;
|
|
24
|
+
const config = await loadConfig();
|
|
25
|
+
if (config.dev) {
|
|
26
|
+
console.log(` \x1b[33m[DEV]\x1b[0m ${config.baseUrl}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Auth
|
|
31
|
+
program.command('login').description('Authenticate via browser (device flow)').action(loginCommand);
|
|
32
|
+
program.command('logout').description('Remove stored credentials').action(logoutCommand);
|
|
33
|
+
program
|
|
34
|
+
.command('whoami')
|
|
35
|
+
.description('Show current user info')
|
|
36
|
+
.option('--json', 'Output as JSON')
|
|
37
|
+
.action(whoamiCommand);
|
|
38
|
+
|
|
39
|
+
// Config
|
|
40
|
+
program
|
|
41
|
+
.command('config')
|
|
42
|
+
.description('View or set configuration')
|
|
43
|
+
.option('--base-url <url>', 'Set the API base URL')
|
|
44
|
+
.option('--json', 'Output as JSON')
|
|
45
|
+
.action(configCommand);
|
|
46
|
+
|
|
47
|
+
// Mode
|
|
48
|
+
program.command('dev').description('Switch to dev mode (localhost:5173)').action(devCommand);
|
|
49
|
+
program.command('prod').description('Switch to prod mode (firstdraftresearch.com)').action(prodCommand);
|
|
50
|
+
|
|
51
|
+
// Transcripts
|
|
52
|
+
const transcripts = program
|
|
53
|
+
.command('transcripts')
|
|
54
|
+
.alias('t')
|
|
55
|
+
.description('Search and read earnings call transcripts');
|
|
56
|
+
|
|
57
|
+
transcripts
|
|
58
|
+
.command('search <query>')
|
|
59
|
+
.description('Search companies by ticker or name (returns exchange:ticker)')
|
|
60
|
+
.option('--json', 'Output as JSON')
|
|
61
|
+
.action(companySearchCommand);
|
|
62
|
+
|
|
63
|
+
transcripts
|
|
64
|
+
.command('list')
|
|
65
|
+
.description('List transcripts by date (cursored, max 500)')
|
|
66
|
+
.option('-l, --limit <n>', 'Max results (max 500)', '50')
|
|
67
|
+
.option('--cursor <date>', 'Cursor from previous page (ISO date)')
|
|
68
|
+
.option('-s, --search <term>', 'Full-text search')
|
|
69
|
+
.option('--yahoo-ticker <ticker>', 'Filter by yahoo ticker (e.g. AAPL, 2330.TW)')
|
|
70
|
+
.option('--types <list>', 'Filter by call types (Q1,Q2,Q3,Q4,CONF,OTHER,CMD)')
|
|
71
|
+
.option('-y, --year <year>', 'Filter by fiscal year')
|
|
72
|
+
.option('--date-from <date>', 'Start date (ISO)')
|
|
73
|
+
.option('--date-to <date>', 'End date (ISO)')
|
|
74
|
+
.option('--json', 'Output as JSON')
|
|
75
|
+
.action(listCommand);
|
|
76
|
+
|
|
77
|
+
transcripts
|
|
78
|
+
.command('get <id>')
|
|
79
|
+
.description('Read a full transcript')
|
|
80
|
+
.option('--json', 'Output as JSON')
|
|
81
|
+
.action(getCommand);
|
|
82
|
+
|
|
83
|
+
transcripts
|
|
84
|
+
.command('company <ticker>')
|
|
85
|
+
.description('List transcripts for a company (yahoo ticker)')
|
|
86
|
+
.option('-l, --limit <n>', 'Max results (max 500)', '50')
|
|
87
|
+
.option('--cursor <date>', 'Cursor from previous page (ISO date)')
|
|
88
|
+
.option('-t, --time-range <range>', 'Time range: 1M,3M,6M,YTD,1Y,2Y,5Y,Max')
|
|
89
|
+
.option('--types <list>', 'Filter by call types (Q1,Q2,Q3,Q4,CONF,OTHER,CMD)')
|
|
90
|
+
.option('-y, --year <year>', 'Filter by fiscal year')
|
|
91
|
+
.option('--date-from <date>', 'Start date (ISO)')
|
|
92
|
+
.option('--date-to <date>', 'End date (ISO)')
|
|
93
|
+
.option('--json', 'Output as JSON')
|
|
94
|
+
.action(companyCommand);
|
|
95
|
+
|
|
96
|
+
transcripts
|
|
97
|
+
.command('last-updated')
|
|
98
|
+
.description('Show when the transcript database was last updated')
|
|
99
|
+
.option('--json', 'Output as JSON')
|
|
100
|
+
.action(lastUpdatedCommand);
|
|
101
|
+
|
|
102
|
+
program.parse();
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"types": ["bun-types"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|