Abrarul8541 commited on
Commit
8fbb9c4
·
verified ·
1 Parent(s): d365785

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
+ }