2026-07-30 20:02:14 +00:00
|
|
|
import { Router } from 'express';
|
|
|
|
|
import { requireAuth } from '../../auth-connector/source/server/auth';
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
history: Saturday, 2 August 2026 — Claude API + Claude Code providers for rojo-chat-panel
- Claude API provider (@anthropic-ai/sdk): streaming, separate session history,
tool use support, rojo-settings-panel claude endpoint type
- Claude Code subprocess provider: --print --output-format stream-json --verbose,
--session-id/--resume multi-turn, shell:false Windows fix,
rojo-settings-panel claude-code endpoint type (no API key)
- Rojo local-mode fixes: detection, creation, system-prompt delivery
- Three subprocess bugs fixed: missing --verbose, stdin blocking, cmd.exe mangling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-02 10:32:11 +00:00
|
|
|
import crypto from 'crypto';
|
2026-07-30 20:02:14 +00:00
|
|
|
|
|
|
|
|
const router = Router();
|
|
|
|
|
router.use( requireAuth );
|
|
|
|
|
|
|
|
|
|
interface FileNode
|
|
|
|
|
{
|
|
|
|
|
name: string;
|
|
|
|
|
path: string;
|
|
|
|
|
type: 'file' | 'directory';
|
|
|
|
|
children?: FileNode[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeResolve( root: string, filePath: string ): string | null
|
|
|
|
|
{
|
|
|
|
|
const resolvedRoot = path.resolve( root );
|
|
|
|
|
const full = path.resolve( path.join( resolvedRoot, filePath ) );
|
|
|
|
|
if ( !full.startsWith( resolvedRoot + path.sep ) && full !== resolvedRoot ) return null;
|
|
|
|
|
return full;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTree( absDir: string, rootDir: string ): FileNode[]
|
|
|
|
|
{
|
2026-07-31 18:43:08 +00:00
|
|
|
return fs.readdirSync( absDir ).filter( name => name !== '.roject' ).map( name =>
|
2026-07-30 20:02:14 +00:00
|
|
|
{
|
|
|
|
|
const abs = path.join( absDir, name );
|
|
|
|
|
const rel = path.relative( rootDir, abs ).replace( /\\/g, '/' );
|
|
|
|
|
if ( fs.statSync( abs ).isDirectory() )
|
|
|
|
|
{
|
|
|
|
|
return { name, path: rel, type: 'directory' as const, children: buildTree( abs, rootDir ) };
|
|
|
|
|
}
|
|
|
|
|
return { name, path: rel, type: 'file' as const };
|
|
|
|
|
} );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.get( '/tree', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const root = req.query.root as string;
|
|
|
|
|
if ( !root ) { res.status( 400 ).json( { error: 'root required' } ); return; }
|
|
|
|
|
const resolvedRoot = path.resolve( root );
|
|
|
|
|
if ( !fs.existsSync( resolvedRoot ) ) { res.status( 404 ).json( { error: 'Directory not found' } ); return; }
|
|
|
|
|
res.json( buildTree( resolvedRoot, resolvedRoot ) );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
router.get( '/read', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const root = req.query.root as string;
|
|
|
|
|
const filePath = req.query.path as string;
|
|
|
|
|
if ( !root || !filePath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
|
|
|
|
const full = safeResolve( root, filePath );
|
|
|
|
|
if ( !full || !fs.existsSync( full ) ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
|
|
|
|
res.type( 'text/plain' ).send( fs.readFileSync( full, 'utf8' ) );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
router.put( '/write', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const root = req.query.root as string;
|
|
|
|
|
const filePath = req.query.path as string;
|
|
|
|
|
if ( !root || !filePath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
|
|
|
|
if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }
|
|
|
|
|
const full = safeResolve( root, filePath );
|
|
|
|
|
if ( !full ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
|
|
|
|
|
fs.mkdirSync( path.dirname( full ), { recursive: true } );
|
|
|
|
|
fs.writeFileSync( full, req.body, 'utf8' );
|
|
|
|
|
res.json( { ok: true } );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
router.post( '/create-file', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const { root, path: filePath } = req.body as { root: string; path: string };
|
|
|
|
|
if ( !root || !filePath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
|
|
|
|
const full = safeResolve( root, filePath );
|
|
|
|
|
if ( !full || fs.existsSync( full ) ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; }
|
|
|
|
|
fs.mkdirSync( path.dirname( full ), { recursive: true } );
|
|
|
|
|
fs.writeFileSync( full, '', 'utf8' );
|
|
|
|
|
res.json( { ok: true } );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
router.post( '/create-directory', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const { root, path: dirPath } = req.body as { root: string; path: string };
|
|
|
|
|
if ( !root || !dirPath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
|
|
|
|
const full = safeResolve( root, dirPath );
|
|
|
|
|
if ( !full || fs.existsSync( full ) ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; }
|
|
|
|
|
fs.mkdirSync( full, { recursive: true } );
|
|
|
|
|
res.json( { ok: true } );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
router.post( '/rename', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const { root, path: oldPath, newName } = req.body as { root: string; path: string; newName: string };
|
|
|
|
|
if ( !root || !oldPath || !newName ) { res.status( 400 ).json( { error: 'root, path, and newName required' } ); return; }
|
|
|
|
|
if ( newName.includes( '/' ) || newName.includes( '\\' ) ) { res.status( 400 ).json( { error: 'Invalid name' } ); return; }
|
|
|
|
|
const full = safeResolve( root, oldPath );
|
|
|
|
|
if ( !full || !fs.existsSync( full ) ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
|
|
|
|
const resolvedRoot = path.resolve( root );
|
|
|
|
|
const newFull = path.join( path.dirname( full ), newName );
|
|
|
|
|
if ( !newFull.startsWith( resolvedRoot + path.sep ) ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
|
|
|
|
|
if ( fs.existsSync( newFull ) ) { res.status( 409 ).json( { error: 'Already exists' } ); return; }
|
|
|
|
|
fs.renameSync( full, newFull );
|
|
|
|
|
res.json( { ok: true } );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
router.post( '/delete', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const { root, path: targetPath } = req.body as { root: string; path: string };
|
|
|
|
|
if ( !root || !targetPath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
|
|
|
|
const full = safeResolve( root, targetPath );
|
|
|
|
|
if ( !full || !fs.existsSync( full ) ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
|
|
|
|
fs.rmSync( full, { recursive: true, force: true } );
|
|
|
|
|
res.json( { ok: true } );
|
|
|
|
|
} );
|
|
|
|
|
|
history: Saturday, 2 August 2026 — Claude API + Claude Code providers for rojo-chat-panel
- Claude API provider (@anthropic-ai/sdk): streaming, separate session history,
tool use support, rojo-settings-panel claude endpoint type
- Claude Code subprocess provider: --print --output-format stream-json --verbose,
--session-id/--resume multi-turn, shell:false Windows fix,
rojo-settings-panel claude-code endpoint type (no API key)
- Rojo local-mode fixes: detection, creation, system-prompt delivery
- Three subprocess bugs fixed: missing --verbose, stdin blocking, cmd.exe mangling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-02 10:32:11 +00:00
|
|
|
// ── Rojo helpers ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function pickRandom<T>( arr: T[] ): T { return arr[ Math.floor( Math.random() * arr.length ) ]; }
|
|
|
|
|
function capitalize( s: string ): string { return s[ 0 ].toUpperCase() + s.slice( 1 ); }
|
|
|
|
|
|
|
|
|
|
const ROJO_ADJECTIVES = [
|
|
|
|
|
"funky", "cool", "groovy", "wild", "sharp", "slick", "swift", "bold",
|
|
|
|
|
"bright", "calm", "daring", "eager", "fierce", "jolly", "lively",
|
|
|
|
|
"quirky", "smart", "vivid", "zesty", "sleek", "crisp", "nifty", "rad",
|
|
|
|
|
"sunny", "sassy", "snappy", "peppy", "zippy", "breezy", "mellow",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const ROJO_ROLES = [
|
|
|
|
|
"boss", "doctor", "player", "teacher", "hunter", "builder", "maker",
|
|
|
|
|
"dancer", "singer", "painter", "writer", "coder", "pilot", "chef",
|
|
|
|
|
"sailor", "rider", "climber", "dreamer", "wanderer", "scout", "keeper",
|
|
|
|
|
"helper", "guide", "mentor", "runner", "seeker", "ranger", "scholar",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const ROJO_NAMES = [
|
|
|
|
|
"Sofia", "Elena", "Lucas", "Mia", "Noah", "Emma", "Leon", "Lena",
|
|
|
|
|
"Felix", "Anna", "Max", "Clara", "Julian", "Sara", "Lars", "Nina",
|
|
|
|
|
"Tom", "Lara", "Erik", "Ida", "Hugo", "Vera", "Otto", "Maja",
|
|
|
|
|
"Kai", "Maya", "Arjun", "Zara", "Aisha", "Omar", "Nala", "Ravi",
|
|
|
|
|
"Yuki", "Kenji", "Amara", "Diego", "Mateo", "Jae", "Sora", "Kira",
|
|
|
|
|
"Rio", "Zion", "Nova", "Leila", "Cyrus", "Noa", "Bao", "Mila",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function generateLocalRojoName(): { display: string; slug: string }
|
|
|
|
|
{
|
|
|
|
|
const adj = pickRandom( ROJO_ADJECTIVES );
|
|
|
|
|
const role = pickRandom( ROJO_ROLES );
|
|
|
|
|
const name = pickRandom( ROJO_NAMES );
|
|
|
|
|
return {
|
|
|
|
|
display: `${ capitalize( adj ) } ${ capitalize( role ) } ${ name }`,
|
|
|
|
|
slug: `${ adj }-${ role }-${ name.toLowerCase() }`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scanLocalRojoFiles( dir: string, rootDir: string, out: string[] = [] ): string[]
|
|
|
|
|
{
|
|
|
|
|
if ( !fs.existsSync( dir ) ) return out;
|
|
|
|
|
|
|
|
|
|
for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ) )
|
|
|
|
|
{
|
|
|
|
|
const full = path.join( dir, entry.name );
|
|
|
|
|
if ( entry.isDirectory() ) { scanLocalRojoFiles( full, rootDir, out ); }
|
|
|
|
|
else if ( entry.name.endsWith( '.rojo' ) )
|
|
|
|
|
{
|
|
|
|
|
out.push( path.relative( rootDir, full ).replace( /\\/g, '/' ) );
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Local rojo list ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
router.get( '/rojos', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const root = req.query.root as string;
|
|
|
|
|
if ( !root ) { res.status( 400 ).json( { error: 'root required' } ); return; }
|
|
|
|
|
|
|
|
|
|
const resolvedRoot = path.resolve( root );
|
|
|
|
|
const rojoDir = path.join( resolvedRoot, 'workspace', 'rojos' );
|
|
|
|
|
const files = scanLocalRojoFiles( rojoDir, resolvedRoot );
|
|
|
|
|
|
|
|
|
|
const rojos = files.map( filePath =>
|
|
|
|
|
{
|
|
|
|
|
let id = '', name = filePath, description = '';
|
|
|
|
|
const full = safeResolve( root, filePath );
|
|
|
|
|
|
|
|
|
|
if ( full && fs.existsSync( full ) )
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
const s = JSON.parse( fs.readFileSync( full, 'utf8' ) );
|
|
|
|
|
id = s.id || '';
|
|
|
|
|
name = s.name || name;
|
|
|
|
|
description = s.description || '';
|
|
|
|
|
}
|
|
|
|
|
catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { id, name, description, path: filePath };
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
res.json( rojos );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
// ── Local rojo create ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
router.post( '/rojos/create', ( req, res ) =>
|
|
|
|
|
{
|
|
|
|
|
const { root, parentDir: parentDirArg } = req.body as { root: string; parentDir?: string };
|
|
|
|
|
if ( !root ) { res.status( 400 ).json( { error: 'root required' } ); return; }
|
|
|
|
|
|
|
|
|
|
const dir = parentDirArg || 'workspace/rojos';
|
|
|
|
|
const { display, slug } = generateLocalRojoName();
|
|
|
|
|
const id = crypto.randomUUID();
|
|
|
|
|
const filePath = `${ dir }/${ slug }.rojo`;
|
|
|
|
|
const full = safeResolve( root, filePath );
|
|
|
|
|
|
|
|
|
|
if ( !full ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
|
|
|
|
|
|
|
|
|
|
const defaults = JSON.stringify(
|
|
|
|
|
{
|
|
|
|
|
id,
|
|
|
|
|
name: display,
|
|
|
|
|
description: '',
|
|
|
|
|
systemPrompt: '',
|
|
|
|
|
endpoint: { type: 'external', url: '', model: '', apiKey: '', tunnelId: '' },
|
|
|
|
|
appearance: { colors: [], layers: [] },
|
|
|
|
|
}, null, 2 );
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync( path.dirname( full ), { recursive: true } );
|
|
|
|
|
fs.writeFileSync( full, defaults, 'utf8' );
|
|
|
|
|
|
|
|
|
|
res.json( { id, path: filePath, name: display } );
|
|
|
|
|
} );
|
|
|
|
|
|
2026-07-30 20:02:14 +00:00
|
|
|
export default router;
|