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:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ssh:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Claude Launcher — PowerShell entry point
|
||||||
|
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
|
||||||
|
Push-Location $PSScriptRoot
|
||||||
|
try { npx tsx src/index.tsx }
|
||||||
|
finally { Pop-Location }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Claude Launcher — Bash entry point
|
||||||
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
cd "$(dirname "$0")" && npx tsx src/index.tsx
|
||||||
Generated
+1427
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-launcher",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Cross-platform Claude Code session manager",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/index.tsx",
|
||||||
|
"dev": "tsx watch src/index.tsx"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"conf": "^13.0.1",
|
||||||
|
"ink": "^5.1.0",
|
||||||
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"react": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "es2022",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user