1818 */
1919
2020import { createInterface } from "node:readline" ;
21+ import { spawn , spawnSync } from "node:child_process" ;
2122import type { Command } from "commander" ;
23+ import { request } from "undici" ;
24+ import chalk from "chalk" ;
2225import { loadConfig } from "../config.js" ;
2326import { MiosaClient } from "../client.js" ;
2427
@@ -974,6 +977,262 @@ async function runServer(): Promise<void> {
974977 }
975978}
976979
980+ // ── install: device-flow auth + auto-wire the host MCP into a client ────────
981+
982+ interface DeviceFlow {
983+ device_code : string ;
984+ user_code : string ;
985+ verification_uri : string ;
986+ verification_uri_complete : string ;
987+ expires_in : number ;
988+ interval : number ;
989+ }
990+
991+ interface TokenResponse {
992+ api_key ?: string ;
993+ error ?: string ;
994+ }
995+
996+ const MCP_REMOTE_URL = "https://api.miosa.ai/api/v1/mcp" ;
997+ const MCP_SERVER_NAME = "miosa" ;
998+
999+ type SupportedClient = "claude" | "cursor" | "gemini" | "manual" ;
1000+
1001+ function sleep ( ms : number ) : Promise < void > {
1002+ return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
1003+ }
1004+
1005+ function openUrl ( url : string ) : void {
1006+ const command =
1007+ process . platform === "darwin"
1008+ ? "open"
1009+ : process . platform === "win32"
1010+ ? "cmd"
1011+ : "xdg-open" ;
1012+ const args = process . platform === "win32" ? [ "/c" , "start" , "" , url ] : [ url ] ;
1013+ const child = spawn ( command , args , { detached : true , stdio : "ignore" } ) ;
1014+ child . unref ( ) ;
1015+ }
1016+
1017+ async function postJson < T > (
1018+ endpoint : string ,
1019+ path : string ,
1020+ body : unknown ,
1021+ ) : Promise < { status : number ; body : T } > {
1022+ const res = await request ( `${ endpoint . replace ( / \/ $ / , "" ) } ${ path } ` , {
1023+ method : "POST" ,
1024+ headers : { "Content-Type" : "application/json" , Accept : "application/json" } ,
1025+ body : JSON . stringify ( body ) ,
1026+ } ) ;
1027+ const text = await res . body . text ( ) ;
1028+ const parsed = text ? ( JSON . parse ( text ) as T ) : ( { } as T ) ;
1029+ return { status : res . statusCode , body : parsed } ;
1030+ }
1031+
1032+ async function runDeviceFlow (
1033+ endpoint : string ,
1034+ clientName : string ,
1035+ ) : Promise < string > {
1036+ const start = await postJson < DeviceFlow > ( endpoint , "/api/v1/auth/cli/start" , {
1037+ client_name : clientName ,
1038+ } ) ;
1039+ if ( start . status >= 400 ) {
1040+ throw new Error ( `Failed to start auth flow (HTTP ${ start . status } )` ) ;
1041+ }
1042+ const flow = start . body ;
1043+
1044+ console . log ( ) ;
1045+ console . log ( chalk . bold ( "Authorize MIOSA MCP for this device" ) ) ;
1046+ console . log ( ) ;
1047+ console . log ( ` Open: ${ chalk . cyan ( flow . verification_uri_complete ) } ` ) ;
1048+ console . log ( ` Code: ${ chalk . bold ( flow . user_code ) } ` ) ;
1049+ console . log ( ) ;
1050+
1051+ try {
1052+ openUrl ( flow . verification_uri_complete ) ;
1053+ console . log ( chalk . dim ( " Browser opened. Waiting for approval..." ) ) ;
1054+ } catch {
1055+ console . log ( chalk . dim ( " Could not open a browser automatically." ) ) ;
1056+ }
1057+
1058+ const deadline = Date . now ( ) + flow . expires_in * 1000 ;
1059+ const intervalMs = Math . max ( flow . interval || 3 , 1 ) * 1000 ;
1060+
1061+ while ( Date . now ( ) < deadline ) {
1062+ await sleep ( intervalMs ) ;
1063+ const poll = await postJson < TokenResponse > (
1064+ endpoint ,
1065+ "/api/v1/auth/cli/token" ,
1066+ { device_code : flow . device_code } ,
1067+ ) ;
1068+ if ( poll . status === 200 && poll . body . api_key ) {
1069+ return poll . body . api_key ;
1070+ }
1071+ if ( poll . status === 428 || poll . body . error === "authorization_pending" ) {
1072+ continue ;
1073+ }
1074+ if ( poll . body . error === "access_denied" ) {
1075+ throw new Error ( "Login was denied in the browser." ) ;
1076+ }
1077+ if ( poll . body . error === "expired_token" || poll . status === 410 ) {
1078+ throw new Error ( "Login request expired. Run the command again." ) ;
1079+ }
1080+ throw new Error (
1081+ `Login failed: ${ poll . body . error ?? `HTTP ${ poll . status } ` } ` ,
1082+ ) ;
1083+ }
1084+
1085+ throw new Error ( "Login timed out. Run the command again." ) ;
1086+ }
1087+
1088+ function wireClaudeCode (
1089+ apiKey : string ,
1090+ remoteUrl : string ,
1091+ scope : "local" | "user" | "project" ,
1092+ ) : { ok : true } | { ok : false ; reason : string } {
1093+ // `claude mcp remove` first so re-running install replaces cleanly.
1094+ spawnSync ( "claude" , [ "mcp" , "remove" , MCP_SERVER_NAME , "--scope" , scope ] , {
1095+ stdio : "ignore" ,
1096+ } ) ;
1097+
1098+ const result = spawnSync (
1099+ "claude" ,
1100+ [
1101+ "mcp" ,
1102+ "add" ,
1103+ "--transport" ,
1104+ "http" ,
1105+ "--scope" ,
1106+ scope ,
1107+ MCP_SERVER_NAME ,
1108+ remoteUrl ,
1109+ "--header" ,
1110+ `Authorization: Bearer ${ apiKey } ` ,
1111+ ] ,
1112+ { stdio : "pipe" , encoding : "utf8" } ,
1113+ ) ;
1114+
1115+ if ( result . error ) {
1116+ return {
1117+ ok : false ,
1118+ reason : `Could not run \`claude\` CLI: ${ result . error . message } ` ,
1119+ } ;
1120+ }
1121+ if ( typeof result . status === "number" && result . status !== 0 ) {
1122+ return {
1123+ ok : false ,
1124+ reason : result . stderr ?. trim ( ) || `claude mcp add exited ${ result . status } ` ,
1125+ } ;
1126+ }
1127+ return { ok : true } ;
1128+ }
1129+
1130+ function printManualSnippet (
1131+ client : SupportedClient ,
1132+ apiKey : string ,
1133+ remoteUrl : string ,
1134+ ) : void {
1135+ const masked =
1136+ apiKey . length > 12
1137+ ? apiKey . slice ( 0 , 6 ) + "…" + apiKey . slice ( - 4 )
1138+ : "msk_u_…" ;
1139+ console . log ( ) ;
1140+ console . log ( chalk . bold ( "Manual install snippet" ) ) ;
1141+ console . log (
1142+ chalk . dim ( ` (your key: ${ masked } — saved to ~/.miosa/config.json)` ) ,
1143+ ) ;
1144+ console . log ( ) ;
1145+
1146+ if ( client === "cursor" ) {
1147+ console . log ( chalk . dim ( " Add to ~/.cursor/mcp.json:" ) ) ;
1148+ console . log ( ) ;
1149+ console . log (
1150+ JSON . stringify (
1151+ {
1152+ mcpServers : {
1153+ miosa : {
1154+ transport : "http" ,
1155+ url : remoteUrl ,
1156+ headers : { Authorization : `Bearer ${ apiKey } ` } ,
1157+ } ,
1158+ } ,
1159+ } ,
1160+ null ,
1161+ 2 ,
1162+ ) ,
1163+ ) ;
1164+ return ;
1165+ }
1166+
1167+ if ( client === "gemini" ) {
1168+ console . log (
1169+ ` ${ chalk . cyan ( `gemini mcp add ${ MCP_SERVER_NAME } ${ remoteUrl } \\` ) } ` ,
1170+ ) ;
1171+ console . log (
1172+ ` ${ chalk . cyan ( `--header "Authorization: Bearer ${ apiKey } "` ) } ` ,
1173+ ) ;
1174+ return ;
1175+ }
1176+
1177+ // claude / manual
1178+ console . log (
1179+ ` ${ chalk . cyan ( `claude mcp add --transport http --scope user ${ MCP_SERVER_NAME } \\` ) } ` ,
1180+ ) ;
1181+ console . log ( ` ${ chalk . cyan ( remoteUrl + " \\" ) } ` ) ;
1182+ console . log (
1183+ ` ${ chalk . cyan ( `--header "Authorization: Bearer ${ apiKey } "` ) } ` ,
1184+ ) ;
1185+ }
1186+
1187+ async function runInstall ( opts : {
1188+ client : SupportedClient ;
1189+ scope : "local" | "user" | "project" ;
1190+ remoteUrl : string ;
1191+ } ) : Promise < void > {
1192+ const config = loadConfig ( ) ;
1193+ const clientName = `MIOSA MCP (${ opts . client === "manual" ? "manual" : opts . client } )` ;
1194+
1195+ console . log (
1196+ chalk . bold ( "MIOSA MCP installer" ) ,
1197+ chalk . dim ( `— wiring ${ opts . client } → ${ opts . remoteUrl } ` ) ,
1198+ ) ;
1199+
1200+ const apiKey = await runDeviceFlow ( config . endpoint , clientName ) ;
1201+
1202+ if ( opts . client === "claude" ) {
1203+ const wired = wireClaudeCode ( apiKey , opts . remoteUrl , opts . scope ) ;
1204+ if ( wired . ok ) {
1205+ console . log ( ) ;
1206+ console . log (
1207+ chalk . green ( "✓" ) ,
1208+ `MCP server '${ MCP_SERVER_NAME } ' added to Claude Code (${ opts . scope } scope).` ,
1209+ ) ;
1210+ console . log ( ) ;
1211+ console . log ( chalk . dim ( "Verify:" ) ) ;
1212+ console . log ( ` ${ chalk . cyan ( "claude mcp list" ) } ` ) ;
1213+ console . log ( ) ;
1214+ console . log ( chalk . dim ( "Try in a fresh Claude Code session:" ) ) ;
1215+ console . log (
1216+ chalk . dim (
1217+ ` "Create a MIOSA sandbox, run \`python -c 'print(2+2)'\`, then destroy it."` ,
1218+ ) ,
1219+ ) ;
1220+ return ;
1221+ }
1222+ console . log ( ) ;
1223+ console . log (
1224+ chalk . yellow ( "!" ) ,
1225+ `Could not auto-wire Claude Code: ${ wired . reason } ` ,
1226+ ) ;
1227+ console . log ( chalk . yellow ( " Falling back to manual snippet:" ) ) ;
1228+ printManualSnippet ( "claude" , apiKey , opts . remoteUrl ) ;
1229+ return ;
1230+ }
1231+
1232+ // cursor / gemini / manual — print the snippet
1233+ printManualSnippet ( opts . client , apiKey , opts . remoteUrl ) ;
1234+ }
1235+
9771236// ── Commander registration ────────────────────────────────────────────────────
9781237
9791238export function register ( program : Command ) : void {
@@ -987,11 +1246,48 @@ export function register(program: Command): void {
9871246 "Start an MCP server over stdio (JSON-RPC 2.0). Add to .claude/mcp.json to use with Claude." ,
9881247 )
9891248 . action ( ( ) => {
990- // runServer() is an infinite async loop; attach an unhandled-rejection guard
9911249 runServer ( ) . catch ( ( e : unknown ) => {
9921250 const msg = e instanceof Error ? e . message : String ( e ) ;
9931251 process . stderr . write ( `miosa mcp serve fatal: ${ msg } \n` ) ;
9941252 process . exit ( 1 ) ;
9951253 } ) ;
9961254 } ) ;
1255+
1256+ mcp
1257+ . command ( "install" )
1258+ . description (
1259+ "Install the hosted MIOSA MCP server (https://api.miosa.ai/api/v1/mcp) into your AI client. Opens a browser to log in and wire your account." ,
1260+ )
1261+ . option (
1262+ "-c, --client <client>" ,
1263+ "Which AI client to wire: claude (default), cursor, gemini, manual" ,
1264+ "claude" ,
1265+ )
1266+ . option (
1267+ "-s, --scope <scope>" ,
1268+ "Claude Code config scope: local, user (default), or project" ,
1269+ "user" ,
1270+ )
1271+ . option (
1272+ "--url <url>" ,
1273+ "Override the hosted MCP URL (default: https://api.miosa.ai/api/v1/mcp)" ,
1274+ MCP_REMOTE_URL ,
1275+ )
1276+ . action ( async ( opts : { client : string ; scope : string ; url : string } ) => {
1277+ const client = (
1278+ [ "claude" , "cursor" , "gemini" , "manual" ] . includes ( opts . client )
1279+ ? opts . client
1280+ : "claude"
1281+ ) as SupportedClient ;
1282+ const scope = (
1283+ [ "local" , "user" , "project" ] . includes ( opts . scope ) ? opts . scope : "user"
1284+ ) as "local" | "user" | "project" ;
1285+ try {
1286+ await runInstall ( { client, scope, remoteUrl : opts . url } ) ;
1287+ } catch ( e : unknown ) {
1288+ const msg = e instanceof Error ? e . message : String ( e ) ;
1289+ console . error ( chalk . red ( `Error: ${ msg } ` ) ) ;
1290+ process . exit ( 3 ) ;
1291+ }
1292+ } ) ;
9971293}
0 commit comments