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:
2026-04-06 20:52:26 +03:00
commit d5d6703a1a
11 changed files with 2329 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ssh:*)"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+5
View File
@@ -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 }
+4
View File
@@ -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
+1427
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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
View File
@@ -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>
);
}
+38
View File
@@ -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;
}
+17
View File
@@ -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
View File
@@ -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`;
}
+14
View File
@@ -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"]
}