Phase 4: CLI router exposing app functions as commands
Browse files
app/src/main/java/io/github/abrarul/modularai/cli/Cli.kt
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package io.github.abrarul.modularai.cli
|
| 2 |
+
|
| 3 |
+
import android.content.Context
|
| 4 |
+
import io.github.abrarul.modularai.bridge.TermuxBridge
|
| 5 |
+
import io.github.abrarul.modularai.engine.CloudBackend
|
| 6 |
+
import io.github.abrarul.modularai.engine.LlamaEngine
|
| 7 |
+
import io.github.abrarul.modularai.i18n.LocaleManager
|
| 8 |
+
import io.github.abrarul.modularai.model.DeviceProfiler
|
| 9 |
+
import io.github.abrarul.modularai.model.ModelDownloader
|
| 10 |
+
import io.github.abrarul.modularai.model.Source
|
| 11 |
+
import io.github.abrarul.modularai.plugin.PluginHost
|
| 12 |
+
import io.github.abrarul.modularai.proot.Proot
|
| 13 |
+
import io.github.abrarul.modularai.security.SecretStore
|
| 14 |
+
import io.github.abrarul.modularai.security.Vault
|
| 15 |
+
import io.github.abrarul.modularai.update.Installer
|
| 16 |
+
import io.github.abrarul.modularai.update.Updater
|
| 17 |
+
import kotlinx.coroutines.Dispatchers
|
| 18 |
+
import kotlinx.coroutines.withContext
|
| 19 |
+
import java.io.File
|
| 20 |
+
|
| 21 |
+
object Config {
|
| 22 |
+
const val LOCALES_URL = "https://huggingface.co/Abrarul8541/ModularAI-Terminal/resolve/main/locales"
|
| 23 |
+
const val UPDATE_URL = "https://huggingface.co/Abrarul8541/ModularAI-Terminal/resolve/main/update.json"
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/** Process-wide engine so a loaded model survives screen changes. */
|
| 27 |
+
object Runtime {
|
| 28 |
+
val engine by lazy { LlamaEngine() }
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
class Cli(private val ctx: Context) {
|
| 32 |
+
private val prefs = ctx.getSharedPreferences("cli", Context.MODE_PRIVATE)
|
| 33 |
+
private val secrets = SecretStore(ctx)
|
| 34 |
+
private val proot = Proot(ctx)
|
| 35 |
+
private val plugins = PluginHost(ctx)
|
| 36 |
+
val locale = LocaleManager(ctx, Config.LOCALES_URL)
|
| 37 |
+
|
| 38 |
+
private fun modelsDir() = File(prefs.getString("models_dir", null) ?: File(ctx.getExternalFilesDir(null) ?: ctx.filesDir, "models").path)
|
| 39 |
+
private fun dl() = ModelDownloader(modelsDir())
|
| 40 |
+
private fun model(name: String) = File(modelsDir(), name).takeIf { it.exists() } ?: error("No such model: $name (see: models)")
|
| 41 |
+
|
| 42 |
+
suspend fun exec(line: String, out: (String) -> Unit) {
|
| 43 |
+
val a = line.trim().split(Regex("\\s+"))
|
| 44 |
+
val rest = { n: Int -> a.drop(n).joinToString(" ") }
|
| 45 |
+
try {
|
| 46 |
+
when (a[0]) {
|
| 47 |
+
"" -> {}
|
| 48 |
+
"help" -> out(HELP)
|
| 49 |
+
"hw" -> {
|
| 50 |
+
val p = withContext(Dispatchers.Default) { DeviceProfiler.scan(ctx) }
|
| 51 |
+
val r = DeviceProfiler.recommend(p)
|
| 52 |
+
out("SoC ${p.soc} | ${p.abi} | ${p.cores} cores | Vulkan ${p.vulkan}")
|
| 53 |
+
out("RAM ${p.totalRamMb} MB (free ${p.availRamMb}) | free storage ${p.freeStorageMb} MB | disk ${"%.0f".format(p.storageMBps)} MB/s")
|
| 54 |
+
out("Tier ${p.tier.label} (${p.tier.sizes}) -> up to ~${"%.1f".format(r.maxParamsB)}B, ${r.quant}, ctx ${r.contextTokens}, est ${r.estTokensPerSec} tok/s")
|
| 55 |
+
}
|
| 56 |
+
"models" -> dl().list().ifEmpty { out("(none) dir: ${modelsDir()}"); emptyList() }
|
| 57 |
+
.forEach { out("%-48s %6d MB".format(it.name, it.length() / 1_048_576)) }
|
| 58 |
+
"pull" -> {
|
| 59 |
+
val src = when (a.getOrNull(1)) {
|
| 60 |
+
"hf" -> Source.HuggingFace(a[2], a[3])
|
| 61 |
+
"ollama" -> Source.Ollama(a[2], a.getOrElse(3) { "latest" })
|
| 62 |
+
"url" -> Source.Url(a[2])
|
| 63 |
+
else -> error("pull hf <repo> <file> | pull ollama <name> [tag] | pull url <url>")
|
| 64 |
+
}
|
| 65 |
+
var last = -1
|
| 66 |
+
val f = dl().download(src) { d, t ->
|
| 67 |
+
val pct = if (t > 0) (d * 100 / t).toInt() / 5 * 5 else 0
|
| 68 |
+
if (pct != last) { last = pct; out(" $pct% ${d / 1_048_576}/${t / 1_048_576} MB") }
|
| 69 |
+
}
|
| 70 |
+
out("Saved ${f.name}")
|
| 71 |
+
}
|
| 72 |
+
"rm" -> out(if (dl().remove(model(a[1]))) "Removed" else "Failed")
|
| 73 |
+
"wipe-models" -> { dl().wipeAll(); out("All models removed") }
|
| 74 |
+
"mount" -> { prefs.edit().putString("models_dir", a[1]).apply(); File(a[1]).mkdirs(); out("Models dir: ${a[1]}") }
|
| 75 |
+
"unmount" -> { prefs.edit().remove("models_dir").apply(); out("Models dir reset to default") }
|
| 76 |
+
"load" -> withContext(Dispatchers.Default) {
|
| 77 |
+
val f = model(a[1])
|
| 78 |
+
val ctxLen = a.getOrNull(2)?.toInt() ?: DeviceProfiler.recommend(DeviceProfiler.scan(ctx)).contextTokens
|
| 79 |
+
Runtime.engine.load(f.path, ctxLen)
|
| 80 |
+
out("Loaded ${f.name} (ctx $ctxLen)")
|
| 81 |
+
}
|
| 82 |
+
"unload" -> { Runtime.engine.unload(); out("Unloaded") }
|
| 83 |
+
"run" -> withContext(Dispatchers.Default) {
|
| 84 |
+
Runtime.engine.generate(rest(1), 512) { out(it); true }
|
| 85 |
+
}
|
| 86 |
+
"key" -> { secrets.put(a[2], a[3]); out("Key stored (encrypted) for ${a[2]}") }
|
| 87 |
+
"cloud" -> withContext(Dispatchers.IO) {
|
| 88 |
+
val key = secrets.get(a[1]) ?: error("No key. Run: key set ${a[1]} <value>")
|
| 89 |
+
CloudBackend(a[1], key, a[2]).generate(rest(3), 1024) { out(it); true }
|
| 90 |
+
}
|
| 91 |
+
"lang" -> {
|
| 92 |
+
if (a.getOrNull(1) == "reset") { locale.reset(); out("Language: en") }
|
| 93 |
+
else if (a.getOrNull(1) == "purge") { locale.purgeAll(); out("Locale cache cleared") }
|
| 94 |
+
else { locale.apply(a[1]); out(locale.t("greeting", "Language applied: ${a[1]}")) }
|
| 95 |
+
}
|
| 96 |
+
"plugins" -> plugins.installed().ifEmpty { out("(none)"); emptyList() }
|
| 97 |
+
.forEach { out("${it.id} v${it.version} perms=${it.permissions} granted=${plugins.isGranted(it)}") }
|
| 98 |
+
"plugin" -> when (a[1]) {
|
| 99 |
+
"grant" -> plugins.installed().first { it.id == a[2] }.let { plugins.grant(it.id, it.permissions); out("Granted ${it.permissions}") }
|
| 100 |
+
"rm" -> plugins.installed().first { it.id == a[2] }.let { plugins.remove(it); out("Removed") }
|
| 101 |
+
else -> error("plugin grant <id> | plugin rm <id>")
|
| 102 |
+
}
|
| 103 |
+
"update" -> withContext(Dispatchers.IO) {
|
| 104 |
+
val u = Updater(Config.UPDATE_URL, ctx.cacheDir)
|
| 105 |
+
val info = u.check()
|
| 106 |
+
if (info == null) out("Up to date") else {
|
| 107 |
+
out("Downloading ${info.versionName}")
|
| 108 |
+
Installer.install(ctx, u.download(info)); out("Installer opened")
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
"proot" -> when (a.getOrNull(1)) {
|
| 112 |
+
"install" -> proot.installAlpine(out)
|
| 113 |
+
"remove" -> { proot.remove(); out("Rootfs removed") }
|
| 114 |
+
"status" -> out("binary=${proot.binaryAvailable} rootfs=${proot.installed}")
|
| 115 |
+
else -> withContext(Dispatchers.IO) { proot.exec(rest(1), out) }
|
| 116 |
+
}
|
| 117 |
+
"termux" -> if (TermuxBridge.isInstalled(ctx)) { TermuxBridge.run(ctx, rest(1)); out("Sent to Termux (background)") } else out("Termux is not installed")
|
| 118 |
+
"su" -> out(TermuxBridge.su(rest(1)) ?: "su unavailable or denied")
|
| 119 |
+
"vault" -> if (a.getOrNull(1) == "wipe") {
|
| 120 |
+
secrets.wipe(); Vault.destroy(); out("Vault key destroyed. Encrypted notes and keys are now unreadable.")
|
| 121 |
+
} else error("vault wipe")
|
| 122 |
+
else -> withContext(Dispatchers.IO) { shell(line, out) } // raw Android shell
|
| 123 |
+
}
|
| 124 |
+
} catch (e: Exception) {
|
| 125 |
+
out("error: ${e.message ?: e.javaClass.simpleName}")
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
private fun shell(cmd: String, out: (String) -> Unit) {
|
| 130 |
+
val p = ProcessBuilder("/system/bin/sh", "-c", cmd).directory(ctx.filesDir).redirectErrorStream(true).start()
|
| 131 |
+
p.inputStream.bufferedReader().forEachLine(out)
|
| 132 |
+
p.waitFor()
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
companion object {
|
| 136 |
+
const val HELP = """hw device profile + recommendation
|
| 137 |
+
models | pull hf <repo> <file> | pull ollama <name> [tag] | pull url <url>
|
| 138 |
+
rm <file> | wipe-models | mount <dir> | unmount
|
| 139 |
+
load <file> [ctx] | run <prompt> | unload
|
| 140 |
+
key set <openai|deepseek|anthropic> <key> (stored encrypted)
|
| 141 |
+
cloud <provider> <model> <prompt>
|
| 142 |
+
lang <code> | lang reset | lang purge
|
| 143 |
+
plugins | plugin grant <id> | plugin rm <id>
|
| 144 |
+
update check, verify and install update
|
| 145 |
+
proot install | proot status | proot <cmd> (Linux rootfs)
|
| 146 |
+
termux <cmd> | su <cmd>
|
| 147 |
+
vault wipe
|
| 148 |
+
<anything else> runs in the Android shell"""
|
| 149 |
+
}
|
| 150 |
+
}
|