Initial commit: Claude Code session launcher
TUI launcher built with Ink/React for managing Claude Code sessions. Features: local project browser, Gitea integration, Infisical auth, remote sessions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+624
@@ -0,0 +1,624 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput, useApp } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as svc from './services.js';
|
||||
import * as cfg from './config.js';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────
|
||||
|
||||
type View =
|
||||
| 'menu'
|
||||
| 'projects'
|
||||
| 'launch'
|
||||
| 'auth-password'
|
||||
| 'auth-secret'
|
||||
| 'gitea'
|
||||
| 'new-project'
|
||||
| 'folder'
|
||||
| 'loading';
|
||||
|
||||
type PendingAction = 'secure' | 'gitea' | null;
|
||||
|
||||
interface AppProps {
|
||||
onLaunch: (action: svc.LaunchAction) => void;
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────
|
||||
|
||||
const MENU_ITEMS = [
|
||||
'Local Projects',
|
||||
'Gitea Repos',
|
||||
'New Project',
|
||||
'Quick Session',
|
||||
'Remote Session (satra)',
|
||||
];
|
||||
|
||||
const LAUNCH_ITEMS = [
|
||||
'Secure (with secrets)',
|
||||
'Quick (no secrets)',
|
||||
'Auto-mode (--dangerously-skip-permissions)',
|
||||
'Remote on satra',
|
||||
'Back',
|
||||
];
|
||||
|
||||
const PAGE = 15;
|
||||
const LINE = '\u2500'.repeat(42);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
// ── Main App ───────────────────────────────────────────
|
||||
|
||||
export function App({ onLaunch }: AppProps) {
|
||||
const { exit } = useApp();
|
||||
const codeDir = svc.getCodeDir();
|
||||
|
||||
// State
|
||||
const [view, setView] = useState<View>('menu');
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [projects, setProjects] = useState<svc.Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<svc.Project | null>(null);
|
||||
const [giteaRepos, setGiteaRepos] = useState<svc.GiteaRepo[]>([]);
|
||||
const [secrets, setSecrets] = useState<Record<string, string> | null>(null);
|
||||
const [infisicalToken, setInfisicalToken] = useState('');
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loadingMsg, setLoadingMsg] = useState('');
|
||||
|
||||
// Text input values
|
||||
const [password, setPassword] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [folderPath, setFolderPath] = useState(codeDir);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const isTextView = ['auth-password', 'auth-secret', 'folder', 'new-project'].includes(view);
|
||||
|
||||
// Init
|
||||
useEffect(() => {
|
||||
setProjects(svc.scanProjects(codeDir, cfg.getLastOpened()));
|
||||
}, []);
|
||||
|
||||
// ── Navigation helpers ──
|
||||
|
||||
function goTo(v: View) {
|
||||
setCursor(0);
|
||||
setError('');
|
||||
setView(v);
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
setError('');
|
||||
switch (view) {
|
||||
case 'projects':
|
||||
case 'gitea':
|
||||
case 'new-project':
|
||||
case 'folder':
|
||||
goTo('menu');
|
||||
break;
|
||||
case 'launch':
|
||||
goTo('projects');
|
||||
break;
|
||||
case 'auth-password':
|
||||
case 'auth-secret':
|
||||
setPassword('');
|
||||
setClientSecret('');
|
||||
if (pendingAction === 'gitea') goTo('menu');
|
||||
else goTo('launch');
|
||||
setPendingAction(null);
|
||||
break;
|
||||
default:
|
||||
goTo('menu');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth flow ──
|
||||
|
||||
async function finishAuth(secret: string) {
|
||||
goTo('loading');
|
||||
setLoadingMsg('Authenticating with Infisical...');
|
||||
try {
|
||||
const token = await svc.authenticateInfisical(secret);
|
||||
setInfisicalToken(token);
|
||||
setLoadingMsg('Fetching secrets...');
|
||||
const s = await svc.fetchSecrets(token);
|
||||
setSecrets(s);
|
||||
|
||||
if (pendingAction === 'secure' && selectedProject) {
|
||||
cfg.recordOpen(selectedProject.name);
|
||||
svc.loadSshKey();
|
||||
onLaunch({
|
||||
cwd: selectedProject.path,
|
||||
env: { ...s, INFISICAL_ACCESS_TOKEN: token },
|
||||
});
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingAction === 'gitea') {
|
||||
const giteaToken = s.GITEA_TOKEN;
|
||||
if (!giteaToken) {
|
||||
setError('GITEA_TOKEN not found in Infisical secrets');
|
||||
goTo('menu');
|
||||
} else {
|
||||
setLoadingMsg('Loading Gitea repos...');
|
||||
const repos = await svc.fetchGiteaRepos(giteaToken);
|
||||
setGiteaRepos(repos);
|
||||
goTo('gitea');
|
||||
}
|
||||
}
|
||||
setPendingAction(null);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
goTo(pendingAction === 'gitea' ? 'menu' : 'launch');
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordSubmit(value: string) {
|
||||
if (!svc.verifyPassword(value)) {
|
||||
setError('Wrong password');
|
||||
setPassword('');
|
||||
return;
|
||||
}
|
||||
setPassword('');
|
||||
setError('');
|
||||
|
||||
const secret = svc.getClientSecret();
|
||||
if (secret) {
|
||||
finishAuth(secret);
|
||||
} else {
|
||||
setView('auth-secret');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSecretSubmit(value: string) {
|
||||
setClientSecret('');
|
||||
finishAuth(value);
|
||||
}
|
||||
|
||||
// ── Menu handlers ──
|
||||
|
||||
function handleMenuSelect(idx: number) {
|
||||
switch (idx) {
|
||||
case 0: // Local Projects
|
||||
setProjects(svc.scanProjects(codeDir, cfg.getLastOpened()));
|
||||
goTo('projects');
|
||||
break;
|
||||
case 1: { // Gitea
|
||||
const token = secrets?.GITEA_TOKEN || process.env.GITEA_TOKEN;
|
||||
if (token) {
|
||||
goTo('loading');
|
||||
setLoadingMsg('Loading Gitea repos...');
|
||||
svc.fetchGiteaRepos(token).then(repos => {
|
||||
setGiteaRepos(repos);
|
||||
goTo('gitea');
|
||||
}).catch(e => {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
goTo('menu');
|
||||
});
|
||||
} else {
|
||||
setPendingAction('gitea');
|
||||
goTo('auth-password');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 2: // New Project
|
||||
setNewName('');
|
||||
goTo('new-project');
|
||||
break;
|
||||
case 3: // Quick Session
|
||||
setFolderPath(codeDir);
|
||||
goTo('folder');
|
||||
break;
|
||||
case 4: { // Remote Session
|
||||
cfg.recordOpen('remote-satra');
|
||||
onLaunch({
|
||||
cwd: '.',
|
||||
ssh: {
|
||||
host: 'satra',
|
||||
command: 'claude --dangerously-skip-permissions',
|
||||
},
|
||||
});
|
||||
exit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleProjectSelect(project: svc.Project) {
|
||||
setSelectedProject(project);
|
||||
goTo('launch');
|
||||
}
|
||||
|
||||
function handleLaunchSelect(idx: number) {
|
||||
if (!selectedProject) return;
|
||||
|
||||
switch (idx) {
|
||||
case 0: // Secure
|
||||
if (secrets) {
|
||||
cfg.recordOpen(selectedProject.name);
|
||||
svc.loadSshKey();
|
||||
onLaunch({
|
||||
cwd: selectedProject.path,
|
||||
env: { ...secrets, INFISICAL_ACCESS_TOKEN: infisicalToken },
|
||||
});
|
||||
exit();
|
||||
} else {
|
||||
setPendingAction('secure');
|
||||
goTo('auth-password');
|
||||
}
|
||||
break;
|
||||
case 1: // Quick
|
||||
cfg.recordOpen(selectedProject.name);
|
||||
onLaunch({ cwd: selectedProject.path });
|
||||
exit();
|
||||
break;
|
||||
case 2: // Auto-mode
|
||||
cfg.recordOpen(selectedProject.name);
|
||||
onLaunch({
|
||||
cwd: selectedProject.path,
|
||||
args: ['--dangerously-skip-permissions'],
|
||||
env: secrets ? { ...secrets, INFISICAL_ACCESS_TOKEN: infisicalToken } : undefined,
|
||||
});
|
||||
exit();
|
||||
break;
|
||||
case 3: // Remote
|
||||
cfg.recordOpen(selectedProject.name);
|
||||
onLaunch({
|
||||
cwd: '.',
|
||||
ssh: {
|
||||
host: 'satra',
|
||||
command: `cd /opt/${selectedProject.name} 2>/dev/null || cd ~; claude --dangerously-skip-permissions`,
|
||||
},
|
||||
});
|
||||
exit();
|
||||
break;
|
||||
case 4: // Back
|
||||
handleBack();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGiteaSelect(repo: svc.GiteaRepo) {
|
||||
const local = projects.find(p => p.name === repo.name);
|
||||
if (local) {
|
||||
setSelectedProject(local);
|
||||
goTo('launch');
|
||||
} else {
|
||||
goTo('loading');
|
||||
setLoadingMsg(`Cloning ${repo.name}...`);
|
||||
try {
|
||||
const path = svc.cloneRepo(repo.ssh_url, codeDir, repo.name);
|
||||
const project: svc.Project = {
|
||||
name: repo.name,
|
||||
path,
|
||||
isGit: true,
|
||||
hasClaude: false,
|
||||
};
|
||||
setSelectedProject(project);
|
||||
setProjects(svc.scanProjects(codeDir, cfg.getLastOpened()));
|
||||
goTo('launch');
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
goTo('gitea');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewProject(name: string) {
|
||||
if (!name.trim()) return;
|
||||
const trimmed = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
goTo('loading');
|
||||
setLoadingMsg(`Creating ${trimmed}...`);
|
||||
try {
|
||||
const path = svc.scaffoldProject(codeDir, trimmed);
|
||||
|
||||
// Try to create Gitea repo if we have the token
|
||||
const giteaToken = secrets?.GITEA_TOKEN || process.env.GITEA_TOKEN;
|
||||
if (giteaToken) {
|
||||
svc.createGiteaRepo(giteaToken, trimmed)
|
||||
.then(() => {
|
||||
try {
|
||||
execSync(
|
||||
`git remote add origin git@git.beletage.ro:gitadmin/${trimmed}.git`,
|
||||
{ cwd: path, stdio: 'ignore' }
|
||||
);
|
||||
} catch { /* ok if it fails */ }
|
||||
})
|
||||
.catch(() => { /* ok */ });
|
||||
}
|
||||
|
||||
cfg.recordOpen(trimmed);
|
||||
onLaunch({ cwd: path });
|
||||
exit();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
goTo('menu');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderSubmit(path: string) {
|
||||
if (!path.trim()) return;
|
||||
cfg.recordOpen(`quick:${path.trim()}`);
|
||||
onLaunch({ cwd: path.trim() });
|
||||
exit();
|
||||
}
|
||||
|
||||
// ── Input handling ──
|
||||
|
||||
// Escape in text views
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) handleBack();
|
||||
}, { isActive: isTextView });
|
||||
|
||||
// Navigation in list views
|
||||
useInput((input, key) => {
|
||||
if (key.escape) { handleBack(); return; }
|
||||
if (input === 'q') { exit(); return; }
|
||||
|
||||
const getMax = () => {
|
||||
switch (view) {
|
||||
case 'menu': return MENU_ITEMS.length - 1;
|
||||
case 'projects': return projects.length - 1;
|
||||
case 'launch': return LAUNCH_ITEMS.length - 1;
|
||||
case 'gitea': return giteaRepos.length - 1;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
if (key.upArrow) { setCursor(c => clamp(c - 1, 0, getMax())); return; }
|
||||
if (key.downArrow) { setCursor(c => clamp(c + 1, 0, getMax())); return; }
|
||||
|
||||
if (key.return) {
|
||||
switch (view) {
|
||||
case 'menu': handleMenuSelect(cursor); break;
|
||||
case 'projects': if (projects[cursor]) handleProjectSelect(projects[cursor]!); break;
|
||||
case 'launch': handleLaunchSelect(cursor); break;
|
||||
case 'gitea': if (giteaRepos[cursor]) handleGiteaSelect(giteaRepos[cursor]!); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Shortcuts
|
||||
if (view === 'projects' && input === 'f' && projects[cursor]) {
|
||||
const p = projects[cursor]!;
|
||||
cfg.toggleFavorite(p.name);
|
||||
setProjects([...projects]);
|
||||
}
|
||||
}, { isActive: !isTextView && view !== 'loading' });
|
||||
|
||||
// ── Scroll ──
|
||||
|
||||
const scrollOffset = Math.max(0, cursor - PAGE + 3);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
function viewTitle(): string {
|
||||
switch (view) {
|
||||
case 'projects': return 'Local Projects';
|
||||
case 'launch': return selectedProject?.name || '';
|
||||
case 'auth-password': return 'Password';
|
||||
case 'auth-secret': return 'Client Secret';
|
||||
case 'gitea': return 'Gitea Repos';
|
||||
case 'new-project': return 'New Project';
|
||||
case 'folder': return 'Quick Session';
|
||||
case 'loading': return '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function hints(): string {
|
||||
if (isTextView) return 'type + enter to confirm | esc back';
|
||||
switch (view) {
|
||||
case 'menu': return 'arrows navigate | enter select | q quit';
|
||||
case 'projects': return 'arrows navigate | enter launch | f fav | esc back | q quit';
|
||||
case 'launch': return 'arrows navigate | enter select | esc back';
|
||||
case 'gitea': return 'arrows navigate | enter select/clone | esc back';
|
||||
case 'loading': return 'please wait...';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Text bold color="cyan">{'\u25C6 Claude Launcher'}</Text>
|
||||
{viewTitle() && <Text dimColor>{' / '}{viewTitle()}</Text>}
|
||||
</Box>
|
||||
<Text dimColor>{LINE}</Text>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Box marginY={0}>
|
||||
<Text color="red">{'\u2717 '}{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Auth status indicator */}
|
||||
{secrets && view === 'menu' && (
|
||||
<Text color="green">{'\u2713 Secrets loaded'}</Text>
|
||||
)}
|
||||
|
||||
{/* ── Menu ── */}
|
||||
{view === 'menu' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{MENU_ITEMS.map((label, i) => (
|
||||
<Box key={label}>
|
||||
<Text color={i === cursor ? 'cyan' : undefined} bold={i === cursor}>
|
||||
{i === cursor ? ' \u25B8 ' : ' '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Projects ── */}
|
||||
{view === 'projects' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{projects.length === 0 ? (
|
||||
<Text dimColor> No projects found in {codeDir}</Text>
|
||||
) : (
|
||||
<>
|
||||
{scrollOffset > 0 && <Text dimColor> \u2191 {scrollOffset} more</Text>}
|
||||
{projects.slice(scrollOffset, scrollOffset + PAGE).map((p, i) => {
|
||||
const idx = i + scrollOffset;
|
||||
const active = idx === cursor;
|
||||
const fav = cfg.isFavorite(p.name);
|
||||
return (
|
||||
<Box key={p.name}>
|
||||
<Text color={active ? 'cyan' : undefined} bold={active}>
|
||||
{active ? ' \u25B8 ' : ' '}
|
||||
</Text>
|
||||
<Text color={p.isGit ? 'green' : 'gray'}>
|
||||
{p.isGit ? '\u25A0' : '\u25A1'}{' '}
|
||||
</Text>
|
||||
<Text color={active ? 'cyan' : undefined} bold={active}>
|
||||
{p.name}
|
||||
</Text>
|
||||
{p.hasClaude && <Text color="magenta">{' \u2726'}</Text>}
|
||||
{fav && <Text color="yellow">{' \u2605'}</Text>}
|
||||
<Text dimColor>
|
||||
{' '}{p.lastOpened ? svc.timeAgo(p.lastOpened) : '\u2014'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{scrollOffset + PAGE < projects.length && (
|
||||
<Text dimColor> \u2193 {projects.length - scrollOffset - PAGE} more</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Launch options ── */}
|
||||
{view === 'launch' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{LAUNCH_ITEMS.map((label, i) => (
|
||||
<Box key={label}>
|
||||
<Text color={i === cursor ? 'cyan' : undefined} bold={i === cursor}>
|
||||
{i === cursor ? ' \u25B8 ' : ' '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Gitea repos ── */}
|
||||
{view === 'gitea' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{giteaRepos.length === 0 ? (
|
||||
<Text dimColor> No repos found</Text>
|
||||
) : (
|
||||
<>
|
||||
{scrollOffset > 0 && <Text dimColor> \u2191 {scrollOffset} more</Text>}
|
||||
{giteaRepos.slice(scrollOffset, scrollOffset + PAGE).map((r, i) => {
|
||||
const idx = i + scrollOffset;
|
||||
const active = idx === cursor;
|
||||
const isLocal = projects.some(p => p.name === r.name);
|
||||
return (
|
||||
<Box key={r.name}>
|
||||
<Text color={active ? 'cyan' : undefined} bold={active}>
|
||||
{active ? ' \u25B8 ' : ' '}
|
||||
</Text>
|
||||
<Text color={isLocal ? 'green' : 'yellow'}>
|
||||
{isLocal ? '\u2713' : '\u2193'}{' '}
|
||||
</Text>
|
||||
<Text color={active ? 'cyan' : undefined} bold={active}>
|
||||
{r.name}
|
||||
</Text>
|
||||
{r.description && <Text dimColor>{' '}{r.description.slice(0, 30)}</Text>}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{scrollOffset + PAGE < giteaRepos.length && (
|
||||
<Text dimColor> \u2193 {giteaRepos.length - scrollOffset - PAGE} more</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Password input ── */}
|
||||
{view === 'auth-password' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text> Password: </Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
mask="*"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Client secret input ── */}
|
||||
{view === 'auth-secret' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text dimColor> No client secret found in env or ~/.infisical-secret</Text>
|
||||
<Box>
|
||||
<Text> Client Secret: </Text>
|
||||
<TextInput
|
||||
value={clientSecret}
|
||||
onChange={setClientSecret}
|
||||
onSubmit={handleSecretSubmit}
|
||||
mask="*"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── New project input ── */}
|
||||
{view === 'new-project' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text dimColor> Creates folder in {codeDir}, inits git, adds CLAUDE.md</Text>
|
||||
<Text dimColor> If Gitea token available, also creates remote repo</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text> Name: </Text>
|
||||
<TextInput
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
onSubmit={handleNewProject}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Folder input ── */}
|
||||
{view === 'folder' && (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text dimColor> Launch Claude Code in any folder (no secrets)</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text> Path: </Text>
|
||||
<TextInput
|
||||
value={folderPath}
|
||||
onChange={setFolderPath}
|
||||
onSubmit={handleFolderSubmit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{view === 'loading' && (
|
||||
<Box marginY={1}>
|
||||
<Text color="yellow">
|
||||
{' '}<Spinner type="dots" />{' '}{loadingMsg}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Text dimColor>{LINE}</Text>
|
||||
<Text dimColor>{hints()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Conf from 'conf';
|
||||
|
||||
const config = new Conf<{
|
||||
lastOpened: Record<string, number>;
|
||||
favorites: string[];
|
||||
}>({
|
||||
projectName: 'claude-launcher',
|
||||
defaults: {
|
||||
lastOpened: {},
|
||||
favorites: [],
|
||||
},
|
||||
});
|
||||
|
||||
export function getLastOpened(): Record<string, number> {
|
||||
return config.get('lastOpened');
|
||||
}
|
||||
|
||||
export function recordOpen(name: string): void {
|
||||
const lo = config.get('lastOpened');
|
||||
lo[name] = Date.now();
|
||||
config.set('lastOpened', lo);
|
||||
}
|
||||
|
||||
export function isFavorite(name: string): boolean {
|
||||
return config.get('favorites').includes(name);
|
||||
}
|
||||
|
||||
export function toggleFavorite(name: string): boolean {
|
||||
const favs = config.get('favorites');
|
||||
const idx = favs.indexOf(name);
|
||||
if (idx >= 0) {
|
||||
favs.splice(idx, 1);
|
||||
} else {
|
||||
favs.push(name);
|
||||
}
|
||||
config.set('favorites', favs);
|
||||
return idx < 0;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { App } from './app.js';
|
||||
import type { LaunchAction } from './services.js';
|
||||
import { executeLaunch } from './services.js';
|
||||
|
||||
let pendingLaunch: LaunchAction | null = null;
|
||||
|
||||
const { waitUntilExit } = render(
|
||||
<App onLaunch={(action: LaunchAction) => { pendingLaunch = action; }} />
|
||||
);
|
||||
|
||||
await waitUntilExit();
|
||||
|
||||
if (pendingLaunch) {
|
||||
executeLaunch(pendingLaunch);
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { spawnSync, execSync } from 'node:child_process';
|
||||
|
||||
// Trust self-signed cert for internal Infisical
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
// Config (same as claude-start.sh — identifiers, not secrets)
|
||||
const INFISICAL_URL = 'https://infisical.beletage.ro';
|
||||
const INFISICAL_CLIENT_ID = 'a5f7eb29-006a-4f08-acb5-9ee5a9b3a232';
|
||||
const INFISICAL_WORKSPACE_ID = '078c998d-43a9-420c-aec4-712011108410';
|
||||
const INFISICAL_ENV = 'prod';
|
||||
const PASSWORD_HASH = 'c72f1e9c65db90d424ba39dead705ecc67d78a4e9647f32e023bbd591d6518c8';
|
||||
|
||||
export interface Project {
|
||||
name: string;
|
||||
path: string;
|
||||
isGit: boolean;
|
||||
hasClaude: boolean;
|
||||
lastOpened?: number;
|
||||
}
|
||||
|
||||
export interface GiteaRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
ssh_url: string;
|
||||
clone_url: string;
|
||||
description: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LaunchAction {
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
args?: string[];
|
||||
ssh?: { host: string; command: string };
|
||||
}
|
||||
|
||||
export function getCodeDir(): string {
|
||||
if (platform() === 'win32') return 'D:\\Code';
|
||||
return join(homedir(), 'Code');
|
||||
}
|
||||
|
||||
export function scanProjects(codeDir: string, lastOpened: Record<string, number>): Project[] {
|
||||
if (!existsSync(codeDir)) return [];
|
||||
return readdirSync(codeDir, { withFileTypes: true })
|
||||
.filter((e: { isDirectory(): boolean; name: string }) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'))
|
||||
.map((e: { name: string }) => {
|
||||
const p = join(codeDir, e.name);
|
||||
return {
|
||||
name: e.name,
|
||||
path: p,
|
||||
isGit: existsSync(join(p, '.git')),
|
||||
hasClaude: existsSync(join(p, 'CLAUDE.md')),
|
||||
lastOpened: lastOpened[e.name],
|
||||
};
|
||||
})
|
||||
.sort((a: Project, b: Project) => {
|
||||
if (a.lastOpened && b.lastOpened) return b.lastOpened - a.lastOpened;
|
||||
if (a.lastOpened) return -1;
|
||||
if (b.lastOpened) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPassword(pw: string): boolean {
|
||||
return createHash('sha256').update(pw).digest('hex') === PASSWORD_HASH;
|
||||
}
|
||||
|
||||
export function getClientSecret(): string | null {
|
||||
if (process.env.INFISICAL_CLIENT_SECRET) return process.env.INFISICAL_CLIENT_SECRET;
|
||||
const f = join(homedir(), '.infisical-secret');
|
||||
if (existsSync(f)) return readFileSync(f, 'utf-8').trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authenticateInfisical(clientSecret: string): Promise<string> {
|
||||
const res = await fetch(`${INFISICAL_URL}/api/v1/auth/universal-auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clientId: INFISICAL_CLIENT_ID, clientSecret }),
|
||||
});
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
if (!data.accessToken) throw new Error('Infisical auth failed — check client secret');
|
||||
return data.accessToken as string;
|
||||
}
|
||||
|
||||
export async function fetchSecrets(token: string): Promise<Record<string, string>> {
|
||||
const url = `${INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${INFISICAL_WORKSPACE_ID}&environment=${INFISICAL_ENV}&secretPath=/`;
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const data = (await res.json()) as { secrets?: Array<{ secretKey: string; secretValue: string }> };
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const s of data.secrets || []) secrets[s.secretKey] = s.secretValue;
|
||||
return secrets;
|
||||
}
|
||||
|
||||
export async function fetchGiteaRepos(token: string): Promise<GiteaRepo[]> {
|
||||
const res = await fetch('https://git.beletage.ro/api/v1/user/repos?limit=50', {
|
||||
headers: { Authorization: `token ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gitea error: ${res.status}`);
|
||||
return (await res.json()) as GiteaRepo[];
|
||||
}
|
||||
|
||||
export async function createGiteaRepo(token: string, name: string): Promise<GiteaRepo> {
|
||||
const res = await fetch('https://git.beletage.ro/api/v1/user/repos', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, auto_init: true, default_branch: 'main' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Create repo failed: ${res.status}`);
|
||||
return (await res.json()) as GiteaRepo;
|
||||
}
|
||||
|
||||
export function cloneRepo(sshUrl: string, codeDir: string, name: string): string {
|
||||
const target = join(codeDir, name);
|
||||
execSync(`git clone "${sshUrl}" "${target}"`, { stdio: 'pipe' });
|
||||
return target;
|
||||
}
|
||||
|
||||
export function scaffoldProject(codeDir: string, name: string): string {
|
||||
const p = join(codeDir, name);
|
||||
mkdirSync(join(p, 'src'), { recursive: true });
|
||||
mkdirSync(join(p, 'docs'), { recursive: true });
|
||||
writeFileSync(join(p, 'CLAUDE.md'), `# ${name}\n\nProject instructions for Claude Code.\n`);
|
||||
try {
|
||||
execSync('git init -b main', { cwd: p, stdio: 'ignore' });
|
||||
} catch {
|
||||
try { execSync('git init', { cwd: p, stdio: 'ignore' }); } catch { /* ok */ }
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function loadSshKey(): void {
|
||||
try {
|
||||
const key = join(homedir(), '.ssh', 'id_ed25519');
|
||||
if (existsSync(key)) execSync(`ssh-add "${key}"`, { stdio: 'ignore' });
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
export function executeLaunch(action: LaunchAction): void {
|
||||
if (action.ssh) {
|
||||
spawnSync('ssh', ['-t', action.ssh.host, action.ssh.command], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} else {
|
||||
spawnSync('claude', action.args || [], {
|
||||
cwd: action.cwd,
|
||||
env: { ...process.env, ...action.env },
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function timeAgo(ts: number): string {
|
||||
const d = Date.now() - ts;
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return 'now';
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
const days = Math.floor(h / 24);
|
||||
if (days < 30) return `${days}d`;
|
||||
return `${Math.floor(days / 30)}mo`;
|
||||
}
|
||||
Reference in New Issue
Block a user