@mahmoudwael/opai 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/LICENSE +21 -0
- package/README.md +391 -0
- package/config.example.json +22 -0
- package/dist/agents/capabilities.js +89 -0
- package/dist/agents/claude.js +76 -0
- package/dist/agents/codex.js +68 -0
- package/dist/agents/run.js +28 -0
- package/dist/agents/types.js +1 -0
- package/dist/back.js +20 -0
- package/dist/cli.js +562 -0
- package/dist/config.js +45 -0
- package/dist/dashboard.js +183 -0
- package/dist/launch-menu.js +53 -0
- package/dist/launch-preferences.js +91 -0
- package/dist/list-cache.js +79 -0
- package/dist/models.js +50 -0
- package/dist/providers/openproject.js +120 -0
- package/dist/providers/types.js +1 -0
- package/dist/query-preferences.js +76 -0
- package/dist/sessions/store.js +72 -0
- package/dist/sessions/sync.js +24 -0
- package/dist/status.js +39 -0
- package/dist/token.js +27 -0
- package/dist/ui-state.js +33 -0
- package/dist/ui.js +204 -0
- package/package.json +57 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { configDir } from '../config.js';
|
|
5
|
+
import { ticketKey } from '../providers/types.js';
|
|
6
|
+
export function sessionTickets(registry, provider) {
|
|
7
|
+
return Object.entries(registry).filter(([key, sessions]) => key.startsWith(`${provider}:`) && sessions.length > 0).map(([key, sessions]) => {
|
|
8
|
+
const ordered = [...sessions].sort((a, b) => Date.parse(b.lastUsedAt ?? b.createdAt) - Date.parse(a.lastUsedAt ?? a.createdAt));
|
|
9
|
+
const id = key.slice(provider.length + 1);
|
|
10
|
+
const snapshot = [...sessions].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).find(session => session.ticket)?.ticket;
|
|
11
|
+
return { key, id, title: snapshot?.title ?? `Ticket #${id}`, status: snapshot?.status, sessions: ordered, lastUsedAt: ordered[0].lastUsedAt ?? ordered[0].createdAt };
|
|
12
|
+
}).sort((a, b) => Date.parse(b.lastUsedAt) - Date.parse(a.lastUsedAt));
|
|
13
|
+
}
|
|
14
|
+
export class SessionStore {
|
|
15
|
+
path;
|
|
16
|
+
constructor(path = join(configDir, 'sessions.json')) {
|
|
17
|
+
this.path = path;
|
|
18
|
+
}
|
|
19
|
+
async all() {
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(await readFile(this.path, 'utf8'));
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
23
|
+
throw new Error('Invalid session registry.');
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code === 'ENOENT')
|
|
28
|
+
return {};
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async list(key) { return ((await this.all())[key] ?? []).map(session => ({ ...session, model: session.model ?? null, effort: session.effort ?? null, initialPrompt: session.initialPrompt ?? null })); }
|
|
33
|
+
async syncTickets(tickets) {
|
|
34
|
+
const data = await this.all();
|
|
35
|
+
let updated = 0;
|
|
36
|
+
for (const ticket of tickets) {
|
|
37
|
+
for (const session of data[ticketKey(ticket)] ?? []) {
|
|
38
|
+
if (JSON.stringify(session.ticket) === JSON.stringify(ticket))
|
|
39
|
+
continue;
|
|
40
|
+
session.ticket = ticket;
|
|
41
|
+
updated++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (updated)
|
|
45
|
+
await this.save(data);
|
|
46
|
+
return updated;
|
|
47
|
+
}
|
|
48
|
+
async save(data) {
|
|
49
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
50
|
+
const temp = `${this.path}.${randomUUID()}.tmp`;
|
|
51
|
+
await writeFile(temp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
52
|
+
await rename(temp, this.path);
|
|
53
|
+
}
|
|
54
|
+
async add(key, session) {
|
|
55
|
+
if (!key.includes(':') || !/^[0-9a-f-]{36}$/i.test(session.sessionId) || !session.cwd || !Number.isFinite(Date.parse(session.createdAt)))
|
|
56
|
+
throw new Error('Invalid session record.');
|
|
57
|
+
const data = await this.all();
|
|
58
|
+
data[key] ??= [];
|
|
59
|
+
if (!data[key].some(item => item.agent === session.agent && item.sessionId === session.sessionId))
|
|
60
|
+
data[key].push({ ...session, usedAt: session.usedAt ?? [session.createdAt] });
|
|
61
|
+
await this.save(data);
|
|
62
|
+
}
|
|
63
|
+
async touch(key, sessionId) {
|
|
64
|
+
const data = await this.all();
|
|
65
|
+
const session = data[key]?.find(item => item.sessionId === sessionId);
|
|
66
|
+
if (!session)
|
|
67
|
+
throw new Error('Session is not registered for this ticket.');
|
|
68
|
+
session.lastUsedAt = new Date().toISOString();
|
|
69
|
+
session.usedAt = [...(session.usedAt ?? [session.createdAt]), session.lastUsedAt];
|
|
70
|
+
await this.save(data);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ticketKey } from '../providers/types.js';
|
|
2
|
+
export async function syncSessionTickets(store, provider, tickets, includeMissing = false) {
|
|
3
|
+
let updated = await store.syncTickets(tickets);
|
|
4
|
+
const unavailable = [];
|
|
5
|
+
if (!includeMissing)
|
|
6
|
+
return { updated, unavailable };
|
|
7
|
+
const listed = new Set(tickets.map(ticketKey));
|
|
8
|
+
const registry = await store.all();
|
|
9
|
+
for (const [key, sessions] of Object.entries(registry)) {
|
|
10
|
+
if (!sessions.length || !key.startsWith(`${provider.identity}:`) || listed.has(key))
|
|
11
|
+
continue;
|
|
12
|
+
const id = key.slice(provider.identity.length + 1);
|
|
13
|
+
try {
|
|
14
|
+
const ticket = await provider.get(id);
|
|
15
|
+
if (ticketKey(ticket) !== key)
|
|
16
|
+
throw new Error('Ticket identity changed.');
|
|
17
|
+
updated += await store.syncTickets([ticket]);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
unavailable.push(id);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { updated, unavailable };
|
|
24
|
+
}
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const faces = ['(˶• ᴗ •˶)⋯', '(˶ᵔ ᴗ ᵔ˶)⌕', '(˶• ⩊ •˶)✧'];
|
|
2
|
+
const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
|
|
3
|
+
export class StatusBar {
|
|
4
|
+
output;
|
|
5
|
+
current = { kind: 'idle', message: 'Ready for your next quest' };
|
|
6
|
+
constructor(output = process.stdout) {
|
|
7
|
+
this.output = output;
|
|
8
|
+
}
|
|
9
|
+
set(kind, message) { this.current = { kind, message }; }
|
|
10
|
+
async run(label, work, success) {
|
|
11
|
+
this.set('loading', label);
|
|
12
|
+
let frame = 0;
|
|
13
|
+
const draw = () => {
|
|
14
|
+
this.output.write(`\r\u001b[2K ${faces[frame % faces.length]} ${spinners[frame % spinners.length]} ${label}`);
|
|
15
|
+
frame++;
|
|
16
|
+
};
|
|
17
|
+
if (this.output.isTTY)
|
|
18
|
+
draw();
|
|
19
|
+
else
|
|
20
|
+
this.output.write(`Loading ${label}...\n`);
|
|
21
|
+
const timer = this.output.isTTY ? setInterval(draw, 120) : undefined;
|
|
22
|
+
try {
|
|
23
|
+
const value = await work();
|
|
24
|
+
this.set('success', success(value));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
this.set('error', `${label} failed`);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
if (timer)
|
|
33
|
+
clearInterval(timer);
|
|
34
|
+
if (this.output.isTTY)
|
|
35
|
+
this.output.write('\r\u001b[2K');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const statusBar = new StatusBar();
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { configDir } from './config.js';
|
|
5
|
+
export const tokenPath = join(configDir, 'token');
|
|
6
|
+
export async function loadApiToken(path = tokenPath, environment = process.env.OPENPROJECT_API_TOKEN) {
|
|
7
|
+
if (environment?.trim())
|
|
8
|
+
return environment.trim();
|
|
9
|
+
try {
|
|
10
|
+
const token = (await readFile(path, 'utf8')).trim();
|
|
11
|
+
return token || undefined;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT')
|
|
15
|
+
return undefined;
|
|
16
|
+
throw new Error(`Could not read OPAI token file at ${path}.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function saveApiToken(token, path = tokenPath) {
|
|
20
|
+
if (!token.trim())
|
|
21
|
+
throw new Error('API token cannot be empty.');
|
|
22
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
24
|
+
await writeFile(temp, `${token.trim()}\n`, { mode: 0o600, flag: 'wx' });
|
|
25
|
+
await chmod(temp, 0o600);
|
|
26
|
+
await rename(temp, path);
|
|
27
|
+
}
|
package/dist/ui-state.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { configDir } from './config.js';
|
|
5
|
+
const rotation = ['rabbit', 'chick', 'idle'];
|
|
6
|
+
function nextIndex(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
8
|
+
return 0;
|
|
9
|
+
const index = value.nextMascotIndex;
|
|
10
|
+
return Number.isInteger(index) && Number(index) >= 0 ? Number(index) % rotation.length : 0;
|
|
11
|
+
}
|
|
12
|
+
export class MascotRotationStore {
|
|
13
|
+
path;
|
|
14
|
+
constructor(path = join(configDir, 'ui-state.json')) {
|
|
15
|
+
this.path = path;
|
|
16
|
+
}
|
|
17
|
+
async next() {
|
|
18
|
+
let index = 0;
|
|
19
|
+
try {
|
|
20
|
+
index = nextIndex(JSON.parse(await readFile(this.path, 'utf8')));
|
|
21
|
+
}
|
|
22
|
+
catch { /* First launch or invalid state starts with the rabbit. */ }
|
|
23
|
+
const mascot = rotation[index];
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
26
|
+
const temp = `${this.path}.${randomUUID()}.tmp`;
|
|
27
|
+
await writeFile(temp, `${JSON.stringify({ nextMascotIndex: (index + 1) % rotation.length }, null, 2)}\n`, { mode: 0o600 });
|
|
28
|
+
await rename(temp, this.path);
|
|
29
|
+
}
|
|
30
|
+
catch { /* Mascot decoration must never prevent OPAI from starting. */ }
|
|
31
|
+
return mascot;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { statusBar } from './status.js';
|
|
3
|
+
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
4
|
+
const paint = (code, value) => color ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
5
|
+
export function createOpaiPalette(enabled = color) {
|
|
6
|
+
const rgb = (red, green, blue) => value => enabled
|
|
7
|
+
? `\u001b[38;2;${red};${green};${blue}m${value}\u001b[0m`
|
|
8
|
+
: value;
|
|
9
|
+
return {
|
|
10
|
+
accent: rgb(148, 226, 213),
|
|
11
|
+
mascot: rgb(245, 194, 231),
|
|
12
|
+
positive: rgb(166, 227, 161),
|
|
13
|
+
warning: rgb(249, 226, 175),
|
|
14
|
+
danger: rgb(243, 139, 168),
|
|
15
|
+
muted: rgb(88, 91, 112)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const opaiPalette = createOpaiPalette();
|
|
19
|
+
export const accent = opaiPalette.accent;
|
|
20
|
+
export const muted = opaiPalette.muted;
|
|
21
|
+
export const bold = (value) => paint(1, value);
|
|
22
|
+
export const warning = opaiPalette.warning;
|
|
23
|
+
export const good = opaiPalette.positive;
|
|
24
|
+
export const danger = opaiPalette.danger;
|
|
25
|
+
const magic = opaiPalette.mascot;
|
|
26
|
+
export function selectionCursor(value = '❯', animated = color) {
|
|
27
|
+
return animated ? `\u001b[5m${value}\u001b[25m` : value;
|
|
28
|
+
}
|
|
29
|
+
function truncate(value, max) {
|
|
30
|
+
const chars = [...value];
|
|
31
|
+
return chars.length > max ? `${chars.slice(0, Math.max(0, max - 1)).join('')}…` : value;
|
|
32
|
+
}
|
|
33
|
+
export function visibleWidth(value) {
|
|
34
|
+
let width = 0;
|
|
35
|
+
for (const char of stripVTControlCharacters(value)) {
|
|
36
|
+
if (/\p{Mark}/u.test(char))
|
|
37
|
+
continue;
|
|
38
|
+
const code = char.codePointAt(0);
|
|
39
|
+
width += code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
|
|
40
|
+
(code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
|
|
41
|
+
(code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe10 && code <= 0xfe6f) ||
|
|
42
|
+
(code >= 0xff00 && code <= 0xff60) || (code >= 0x1f300 && code <= 0x1faff)) ? 2 : 1;
|
|
43
|
+
}
|
|
44
|
+
return width;
|
|
45
|
+
}
|
|
46
|
+
function truncateVisible(value, max) {
|
|
47
|
+
if (max <= 0)
|
|
48
|
+
return '';
|
|
49
|
+
if (visibleWidth(value) <= max)
|
|
50
|
+
return value;
|
|
51
|
+
let result = '';
|
|
52
|
+
let width = 0;
|
|
53
|
+
for (const char of value) {
|
|
54
|
+
const next = visibleWidth(char);
|
|
55
|
+
if (width + next > max - 1)
|
|
56
|
+
break;
|
|
57
|
+
result += char;
|
|
58
|
+
width += next;
|
|
59
|
+
}
|
|
60
|
+
return `${result}…`;
|
|
61
|
+
}
|
|
62
|
+
function padVisible(value, width) { return value + ' '.repeat(Math.max(0, width - visibleWidth(value))); }
|
|
63
|
+
export function ticketRow(ticket, columns = process.stdout.columns ?? 80) {
|
|
64
|
+
const rawBadge = ticket.type === 'Bug' ? 'Bug' : ticket.type === 'User Story' ? 'US' : truncate(ticket.typeLabel, 24);
|
|
65
|
+
const usableWidth = Math.max(20, columns - 4);
|
|
66
|
+
const typeWidth = 3;
|
|
67
|
+
const statusWidth = 11;
|
|
68
|
+
const priorityWidth = 8;
|
|
69
|
+
const fixedWidth = 8 + 1 + 2 + typeWidth + 1 + statusWidth + 2 + priorityWidth;
|
|
70
|
+
const titleWidth = Math.max(6, Math.min(58, usableWidth - fixedWidth));
|
|
71
|
+
const id = truncate(`#${ticket.id}`, 8).padEnd(8);
|
|
72
|
+
const title = truncate(ticket.title, titleWidth).padEnd(titleWidth);
|
|
73
|
+
const type = truncate(rawBadge, typeWidth).padEnd(typeWidth);
|
|
74
|
+
const status = truncate(ticket.status, statusWidth).padEnd(statusWidth);
|
|
75
|
+
const priority = truncate(ticket.priority?.name ?? '', priorityWidth).padEnd(priorityWidth);
|
|
76
|
+
const badge = ticket.type === 'Bug' ? danger(type) : ticket.type === 'User Story' ? accent(type) : warning(type);
|
|
77
|
+
return `${accent(id)} ${title} ${badge} ${muted(status)} ${ticket.priority ? bold(priority) : priority}`;
|
|
78
|
+
}
|
|
79
|
+
export function listHighlight(value, columns = process.stdout.columns ?? 80) {
|
|
80
|
+
const width = Math.max(1, columns - 2);
|
|
81
|
+
return stripVTControlCharacters(value).split('\n').map(line => {
|
|
82
|
+
const row = line.padEnd(width);
|
|
83
|
+
const animatedRow = row.startsWith('❯') ? `${selectionCursor('❯')}${row.slice(1)}` : row;
|
|
84
|
+
return color ? `\u001b[7m${animatedRow}\u001b[0m` : row;
|
|
85
|
+
}).join('\n');
|
|
86
|
+
}
|
|
87
|
+
export function sinceLastOpened(value, now = Date.now()) {
|
|
88
|
+
const elapsed = now - Date.parse(value);
|
|
89
|
+
if (!Number.isFinite(elapsed) || elapsed < 0)
|
|
90
|
+
return 'just now';
|
|
91
|
+
const minutes = Math.floor(elapsed / 60_000);
|
|
92
|
+
if (minutes < 1)
|
|
93
|
+
return 'just now';
|
|
94
|
+
if (minutes < 60)
|
|
95
|
+
return `${minutes}m ago`;
|
|
96
|
+
const hours = Math.floor(minutes / 60);
|
|
97
|
+
if (hours < 24)
|
|
98
|
+
return `${hours}h ago`;
|
|
99
|
+
const days = Math.floor(hours / 24);
|
|
100
|
+
if (days < 30)
|
|
101
|
+
return `${days}d ago`;
|
|
102
|
+
const months = Math.floor(days / 30);
|
|
103
|
+
if (months < 12)
|
|
104
|
+
return `${months}mo ago`;
|
|
105
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
106
|
+
}
|
|
107
|
+
export function sessionRow(group, columns = process.stdout.columns ?? 80, now = Date.now()) {
|
|
108
|
+
const agents = [...new Set(group.sessions.map(session => session.agent === 'claude' ? 'Claude' : 'Codex'))].join(' + ');
|
|
109
|
+
const details = `${agents} · ${group.sessions.length} session${group.sessions.length === 1 ? '' : 's'} · last opened ${sinceLastOpened(group.lastUsedAt, now)}`;
|
|
110
|
+
const usableWidth = Math.max(20, columns - 4);
|
|
111
|
+
const statusWidth = 20;
|
|
112
|
+
const titleWidth = Math.max(8, Math.min(58, usableWidth - 8 - 2 - 2 - statusWidth));
|
|
113
|
+
const id = truncate(`#${group.id}`, 8).padEnd(8);
|
|
114
|
+
const title = truncate(group.title, titleWidth).padEnd(titleWidth);
|
|
115
|
+
const rawStatus = group.status ? `[${truncate(group.status, 18)}]` : '[Status unavailable]';
|
|
116
|
+
const status = rawStatus.padEnd(statusWidth);
|
|
117
|
+
const detailIndent = ' ';
|
|
118
|
+
const visibleDetails = truncateVisible(details, Math.max(1, usableWidth - detailIndent.length));
|
|
119
|
+
return `${accent(id)} ${bold(title)} ${group.status ? accent(status) : muted(status)}\n${detailIndent}${muted(visibleDetails)}`;
|
|
120
|
+
}
|
|
121
|
+
const mascots = {
|
|
122
|
+
idle: [' /\\_/\\', '(˶ᵔ ᵕ ᵔ˶)✧', ' /|☆|\\'],
|
|
123
|
+
rabbit: [' /) /)', '(˶ᵔ ᵕ ᵔ˶)', ' /づ♡づ'],
|
|
124
|
+
chick: [' ,_,', ' (˶•ө•˶)', ' /づ✦づ'],
|
|
125
|
+
claude: [' /\\_/\\', '(˶ᵔ ᴗ ᵔ˶)✦', ' /|⌁|\\'],
|
|
126
|
+
codex: [' /\\_/\\', '(˶• ⩊ •˶)⚙', ' /|#|\\'],
|
|
127
|
+
resume: [' /\\_/\\', '(˶ᵔ ᴗ ᵔ˶)↻', ' /|☆|\\'],
|
|
128
|
+
loading: [' /\\_/\\', '(˶• ᴗ •˶)⋯', ' /|…|\\'],
|
|
129
|
+
success: [' /\\_/\\', '(˶ᵔ ᴗ ᵔ˶)☆', ' /|☆|\\'],
|
|
130
|
+
error: [' /\\_/\\', '(˶• ᴗ •˶)♡', ' /|!|\\']
|
|
131
|
+
};
|
|
132
|
+
const mascotWidth = Math.max(...Object.values(mascots).flat().map(visibleWidth));
|
|
133
|
+
let idleMascotMood = 'idle';
|
|
134
|
+
export function setIdleMascotMood(mood) {
|
|
135
|
+
idleMascotMood = mood;
|
|
136
|
+
}
|
|
137
|
+
function headerParts(title, subtitle, status, columns, mood) {
|
|
138
|
+
const art = mascots[mood];
|
|
139
|
+
const symbol = status.kind === 'success' ? '✓' : status.kind === 'error' ? '!' : status.kind === 'loading' ? '◌' : status.kind === 'cached' ? '◆' : '◇';
|
|
140
|
+
const content = [`OPAI › ${title}`, subtitle ?? '', `${symbol} ${status.message}`];
|
|
141
|
+
const contentStart = 2 + mascotWidth + 3;
|
|
142
|
+
if (columns < contentStart + 8) {
|
|
143
|
+
return { art: art.map(line => ` ${line}`), content: content.map(line => ` ${truncateVisible(line, Math.max(1, columns - 2))}`), stacked: true };
|
|
144
|
+
}
|
|
145
|
+
const available = Math.max(1, columns - contentStart);
|
|
146
|
+
return { art: art.map(line => ` ${padVisible(line, mascotWidth)} `), content: content.map(line => truncateVisible(line, available)), stacked: false };
|
|
147
|
+
}
|
|
148
|
+
export function renderHeader(title, subtitle, status, columns = 80, mood = idleMascotMood) {
|
|
149
|
+
const parts = headerParts(title, subtitle, status, columns, mood);
|
|
150
|
+
return parts.stacked
|
|
151
|
+
? [...parts.art, ...parts.content].join('\n')
|
|
152
|
+
: parts.art.map((art, index) => `${art}${parts.content[index]}`).join('\n');
|
|
153
|
+
}
|
|
154
|
+
function centeredLine(value, columns, style = text => text) {
|
|
155
|
+
const fitted = truncateVisible(value, Math.max(1, columns));
|
|
156
|
+
const left = Math.max(0, Math.floor((columns - visibleWidth(fitted)) / 2));
|
|
157
|
+
return `${' '.repeat(left)}${style(fitted)}`;
|
|
158
|
+
}
|
|
159
|
+
export function renderAgentClosed(detail, mood = 'success', columns = 80) {
|
|
160
|
+
return [
|
|
161
|
+
...mascots[mood].map(line => centeredLine(line, columns, magic)),
|
|
162
|
+
'',
|
|
163
|
+
centeredLine('Session closed', columns, value => bold(accent(value))),
|
|
164
|
+
centeredLine(detail, columns, mood === 'error' ? warning : muted)
|
|
165
|
+
].join('\n');
|
|
166
|
+
}
|
|
167
|
+
export function centeredMenuChoices(labels, columns = process.stdout.columns ?? 80) {
|
|
168
|
+
const width = Math.max(0, ...labels.map(visibleWidth));
|
|
169
|
+
const left = Math.max(0, Math.floor((columns - width - 2) / 2));
|
|
170
|
+
return labels.map(label => `${' '.repeat(left)}${label}`);
|
|
171
|
+
}
|
|
172
|
+
export function agentClosedScreen(detail, mood = 'success') {
|
|
173
|
+
if (process.stdout.isTTY)
|
|
174
|
+
process.stdout.write('\u001b[2J\u001b[H');
|
|
175
|
+
console.log(`\n${renderAgentClosed(detail, mood, process.stdout.columns ?? 80)}\n`);
|
|
176
|
+
}
|
|
177
|
+
export function screen(title, subtitle, mood) {
|
|
178
|
+
if (process.stdout.isTTY)
|
|
179
|
+
process.stdout.write('\u001b[2J\u001b[H');
|
|
180
|
+
const { kind, message } = statusBar.current;
|
|
181
|
+
const selectedMood = mood ?? (kind === 'loading' ? 'loading' : kind === 'success' ? 'success' : kind === 'error' ? 'error' : idleMascotMood);
|
|
182
|
+
const parts = headerParts(title, subtitle, { kind, message }, process.stdout.columns ?? 80, selectedMood);
|
|
183
|
+
const styleContent = (value, index) => index === 0 ? bold(accent(value)) : index === 1 ? muted(value) : kind === 'success' ? good(value) : kind === 'error' ? warning(value) : kind === 'loading' ? accent(value) : muted(value);
|
|
184
|
+
const styled = parts.stacked
|
|
185
|
+
? [...parts.art.map(magic), ...parts.content.map(styleContent)].join('\n')
|
|
186
|
+
: parts.art.map((art, index) => `${magic(art)}${styleContent(parts.content[index], index)}`).join('\n');
|
|
187
|
+
console.log(`\n${styled}\n`);
|
|
188
|
+
}
|
|
189
|
+
export function hint() { return muted('↑↓ move · type to filter · Enter select · Esc back · Ctrl+C exit'); }
|
|
190
|
+
export function renderGoodbye(mood = idleMascotMood) {
|
|
191
|
+
const art = mood === 'rabbit'
|
|
192
|
+
? [' /) /)', '₍ᐢ..ᐢ₎♡', ' /づづ']
|
|
193
|
+
: [' /\\_/\\', '(˶ᵔ ᵕ ᵔ˶)ノ', ' /|☆|\\'];
|
|
194
|
+
return [
|
|
195
|
+
` ${magic(art[0])}`,
|
|
196
|
+
` ${magic(art[1])} ${bold('See you next quest, adventurer!')} ${accent('✦')}`,
|
|
197
|
+
` ${magic(art[2])} ${muted('Your saved sessions will be here when you return.')}`
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
export function goodbye() {
|
|
201
|
+
if (!process.stdout.isTTY)
|
|
202
|
+
return;
|
|
203
|
+
console.log(`\n${renderGoodbye()}\n`);
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mahmoudwael/opai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browse OpenProject tickets, launch Claude Code or Codex, and resume the exact native agent session.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opai": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"config.example.json"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
15
|
+
"build": "npm run clean && tsc --project tsconfig.build.json && chmod +x dist/cli.js",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "npm run clean && tsc && chmod +x dist/cli.js && node dist/opai.test.js",
|
|
18
|
+
"verify": "npm run typecheck && npm test",
|
|
19
|
+
"prepack": "npm run build",
|
|
20
|
+
"prepublishOnly": "npm run verify"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"author": {
|
|
26
|
+
"name": "Mahmoud Wael",
|
|
27
|
+
"email": "mahmoud.wael.raslan@gmail.com",
|
|
28
|
+
"url": "https://github.com/MahmoudWael"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/MahmoudWael/opai.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/MahmoudWael/opai#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/MahmoudWael/opai/issues"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"openproject",
|
|
41
|
+
"claude-code",
|
|
42
|
+
"codex",
|
|
43
|
+
"tickets",
|
|
44
|
+
"cli",
|
|
45
|
+
"developer-tools"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@inquirer/prompts": "^7.10.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^24.0.0",
|
|
55
|
+
"typescript": "^5.9.3"
|
|
56
|
+
}
|
|
57
|
+
}
|