chore: initialize slhaf hub project

This commit is contained in:
2026-02-24 17:57:03 +08:00
commit 50cdd8e126
17 changed files with 2226 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.gradle/
build/
out/
.idea/
.kotlin/
*.db
scripts/.host-api-token

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# Kotlin Script Host (Gradle Project)
This project provides two runtime entrypoints while keeping dynamic script loading from `scripts/`.
## Run CLI host
```bash
cd /tmp/kotlin-scripts
./gradlew runCli --args='scripts/hello.hub.kts'
./gradlew runCli --args='scripts/hello.hub.kts --arg=name=Codex --arg=upper=true'
```
Watch mode:
```bash
./gradlew runCli --args='scripts/hello.hub.kts --watch --debounce-ms=200'
```
## Run Web host (Ktor)
```bash
./gradlew runWeb --args='--port=8080 --scripts-dir=./scripts'
```
Auth:
- Use `Authorization: Bearer <token>` for all APIs except `/health`.
- Token source:
- Preferred: set env `HOST_API_TOKEN`.
- Otherwise host auto-generates a token and stores it at `scripts/.host-api-token`.
Routes:
- `GET /health`
- `GET /scripts`
- `GET /scripts/{script}` (raw script content)
- `POST /scripts/{script}`
- `PUT /scripts/{script}`
- `DELETE /scripts/{script}`
- `GET /meta/{script}`
- `GET /run/{script}?k=v`
- `POST /run/{script}?k=v`
Examples:
```bash
curl 'http://127.0.0.1:8080/health'
TOKEN="$(cat scripts/.host-api-token)"
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts'
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts/hello'
curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/scripts/new-api' --data-binary $'// @desc: new api\nval args: Array<String> = emptyArray()\nprintln("ok")'
curl -H "Authorization: Bearer $TOKEN" -X PUT 'http://127.0.0.1:8080/scripts/new-api' --data-binary $'// @desc: new api v2\nval args: Array<String> = emptyArray()\nprintln("ok-v2")'
curl -H "Authorization: Bearer $TOKEN" -X DELETE 'http://127.0.0.1:8080/scripts/new-api'
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/meta/hello'
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/run/hello?name=Alice&upper=true'
curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/run/hello?name=Alice' -d 'from-body'
```
## Script Metadata & Args (`*.hub.kts`)
Scripts declare metadata in comments and receive request arguments through explicit `args` declaration:
```kotlin
// @desc: Demo greeting API
// @param: name | default=world | desc=Name to greet
// @param: token | required=true | desc=Required token
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
val name = kv["name"] ?: "world"
val token = kv["token"] ?: error("token required")
println("hello $name, token=$token")
```
## Dynamic scripts
You can add/remove `*.hub.kts` files in `scripts/` at any time. The web host resolves scripts by route name (`/run/{script}` -> `scripts/{script}.hub.kts`) on each request, so newly added scripts are available immediately.
## Notes
- This keeps runtime behavior dynamic; Gradle is used for dependency resolution and launching, not for precompiling scripts.
- IDE completion for regular Kotlin sources (`src/main/kotlin`) is fully modelled by Gradle.
- You do not need a package/build artifact step before each run. `runCli` and `runWeb` launch directly from source; scripts are compiled on-demand per execution/request.
- For script files with custom extension (`*.hub.kts`), IDEA code insight is usually weaker than standard `*.main.kts` or module Kotlin sources. This is an IDE limitation for custom script definitions.
## Command CLI
A standalone CLI script is available at `tools/api-cli.main.kts` (independent from host internals, only HTTP calls).
Examples:
```bash
kotlin tools/api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list
kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token show hello
kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true
kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token create demo --text='// @desc: demo\nval args: Array<String> = emptyArray()\nprintln("ok")'
```
Note:
- In this environment, `elide run <kts> -- <args...>` currently does not expose Kotlin script args reliably; use `kotlin` to run the CLI script.
## Simple TUI
A minimal keyboard-driven TUI is available at `tools/api-tui.main.kts`.
Run:
```bash
kotlin tools/api-tui.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token
```
Keys:
- `Up/Down` or `j/k`: switch script
- `Left/Right` or `h/l`: switch action (`Refresh/Show/Run/Meta/Create/Edit/Delete/Quit`)
- `Enter`: execute selected action
- `q`: quit
Create/Edit/Delete behavior:
- `Create`: prompt script name, then choose source mode:
- `e` (default): create temp file, open terminal editor, then upload via API
- `f`: read a specified local file and upload via API
- In editor mode, if content is unchanged from initial template, creation is cancelled
- `Edit`: fetch current script content (`GET /scripts/{script}`), write to temp file, open editor, save+exit, then upload via `PUT`
- `Delete`: asks confirmation before calling `DELETE`
- `Run`: prompts for optional query args (`k=v`, separated by `&` or space), and optional POST mode/body
- Now uses a keyboard-driven sub-menu (`Method/Query/Body/Execute/Cancel`) and remembers last run config per script during the session
Editor selection:
- First uses `$EDITOR`
- Fallback to first available of `nvim`, `vim`, `nano`

49
build.gradle.kts Normal file
View File

@@ -0,0 +1,49 @@
plugins {
kotlin("jvm") version "2.2.20"
application
}
val kotlinVersion = "2.2.20"
repositories {
mavenCentral()
}
dependencies {
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlin:kotlin-scripting-jvm-host:$kotlinVersion")
implementation("org.jetbrains.kotlin:kotlin-scripting-jvm:$kotlinVersion")
implementation("org.jetbrains.kotlin:kotlin-scripting-dependencies:$kotlinVersion")
implementation("org.jetbrains.kotlin:kotlin-scripting-dependencies-maven:$kotlinVersion")
implementation("com.google.guava:guava:28.2-jre")
implementation("io.ktor:ktor-server-core-jvm:2.3.13")
implementation("io.ktor:ktor-server-netty-jvm:2.3.13")
runtimeOnly("ch.qos.logback:logback-classic:1.5.18")
}
kotlin {
jvmToolchain(17)
}
application {
mainClass.set("work.slhaf.hub.CliHostKt")
}
val runCli by tasks.registering(JavaExec::class) {
group = "application"
description = "Run the script CLI host"
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("work.slhaf.hub.CliHostKt")
workingDir = projectDir
}
val runWeb by tasks.registering(JavaExec::class) {
group = "application"
description = "Run the script web host"
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("work.slhaf.hub.WebHostKt")
workingDir = projectDir
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

245
gradlew vendored Executable file
View File

@@ -0,0 +1,245 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

1
settings.gradle.kts Normal file
View File

@@ -0,0 +1 @@
rootProject.name = "kotlin-scripts-host"

View File

@@ -0,0 +1,134 @@
package work.slhaf.hub
import java.io.File
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.StandardWatchEventKinds
private fun usage() {
println(
"""
Usage:
./gradlew runCli --args='<script.hub.kts> [--arg=key=value ...] [--body=text] [--watch] [--debounce-ms=300]'
Examples:
./gradlew runCli --args='scripts/hello.hub.kts'
./gradlew runCli --args='scripts/hello.hub.kts --arg=name=Codex'
./gradlew runCli --args='scripts/hello.hub.kts --watch --debounce-ms=200'
""".trimIndent()
)
}
private fun parseDebounce(cliArgs: List<String>): Long {
val token = cliArgs.firstOrNull { it.startsWith("--debounce-ms=") } ?: return 300L
return token.substringAfter("=").toLongOrNull()?.coerceAtLeast(50L) ?: 300L
}
private fun parseScriptArgs(cliArgs: List<String>): List<String> =
cliArgs.asSequence()
.filter { it.startsWith("--arg=") }
.map {
val token = it.substringAfter("--arg=")
token
}
.toList()
private fun parseBody(cliArgs: List<String>): String? =
cliArgs.firstOrNull { it.startsWith("--body=") }?.substringAfter("=")
private fun runOnce(scriptFile: File, requestContext: ScriptRequestContext) {
println("\\n=== Evaluating ${scriptFile.absolutePath} @ ${java.time.LocalTime.now()} ===")
val result = evalAndCapture(scriptFile, requestContext)
if (result.output.isNotBlank()) {
println(result.output)
}
if (result.metadata.description != null) {
println("[META] description: ${result.metadata.description}")
}
if (result.metadata.params.isNotEmpty()) {
println(
"[META] params: " + result.metadata.params.joinToString(", ") { p ->
"${p.name}(required=${p.required}, default=${p.defaultValue ?: "null"})"
}
)
}
if (result.ok) {
println("[OK] Script evaluation finished")
} else {
println("[FAIL] Script evaluation failed")
}
}
fun main(args: Array<String>) {
val rawArgs = args.toList()
if (rawArgs.isEmpty() || rawArgs.contains("--help") || rawArgs.contains("-h")) {
usage()
kotlin.system.exitProcess(if (rawArgs.isEmpty()) 1 else 0)
}
val scriptPath = rawArgs.firstOrNull { !it.startsWith("--") }
if (scriptPath == null) {
usage()
kotlin.system.exitProcess(1)
}
val scriptFile = File(scriptPath).absoluteFile
if (!scriptFile.exists()) {
println("Script file not found: ${scriptFile.absolutePath}")
kotlin.system.exitProcess(2)
}
val watch = rawArgs.contains("--watch")
val debounceMs = parseDebounce(rawArgs)
val requestContext = ScriptRequestContext(
args = parseScriptArgs(rawArgs),
body = parseBody(rawArgs)
)
runOnce(scriptFile, requestContext)
if (!watch) return
val watcher = FileSystems.getDefault().newWatchService()
val dir: Path = scriptFile.parentFile.toPath()
dir.register(
watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
)
println("[WATCH] Watching ${scriptFile.absolutePath}, debounce=${debounceMs}ms")
println("[WATCH] Press Ctrl+C to stop")
var lastStamp = scriptFile.takeIf { it.exists() }?.let { "${it.length()}-${it.lastModified()}" } ?: "MISSING"
while (true) {
val key = watcher.take()
var shouldReload = false
for (event in key.pollEvents()) {
val changed = event.context()?.toString() ?: continue
if (changed == scriptFile.name) {
shouldReload = true
}
}
key.reset()
if (!shouldReload) continue
Thread.sleep(debounceMs)
val currentStamp = scriptFile.takeIf { it.exists() }?.let { "${it.length()}-${it.lastModified()}" } ?: "MISSING"
if (currentStamp == lastStamp) continue
lastStamp = currentStamp
if (!scriptFile.exists()) {
println("[WATCH] Script deleted: ${scriptFile.absolutePath}")
continue
}
runOnce(scriptFile, requestContext)
}
}

View File

@@ -0,0 +1,244 @@
package work.slhaf.hub
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.util.concurrent.ConcurrentHashMap
import kotlin.script.experimental.api.*
import kotlin.script.experimental.dependencies.*
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
import kotlin.script.experimental.host.toScriptSource
import kotlin.script.experimental.jvm.JvmDependency
import kotlin.script.experimental.jvm.jvm
import kotlin.script.experimental.jvm.updateClasspath
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
private val scriptingHost = BasicJvmScriptingHost()
private val evalLock = Any()
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
fun explicitClasspathFromEnv(): List<File>? {
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
if (value.isBlank()) return null
return value.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File)
}
private fun runtimeClasspathFromJavaProperty(): List<File> {
val raw = System.getProperty("java.class.path").orEmpty()
if (raw.isBlank()) return emptyList()
return raw
.split(File.pathSeparator)
.asSequence()
.filter { it.isNotBlank() }
.map(::File)
.filter { it.exists() }
.distinctBy { it.absolutePath }
.toList()
}
private fun configureMavenDepsOnAnnotations(
context: ScriptConfigurationRefinementContext
): ResultWithDiagnostics<ScriptCompilationConfiguration> {
val annotations = context.collectedData
?.get(ScriptCollectedData.collectedAnnotations)
?.takeIf { it.isNotEmpty() }
?: return context.compilationConfiguration.asSuccess()
return runBlocking {
resolver.resolveFromScriptSourceAnnotations(annotations)
}.onSuccess { files ->
context.compilationConfiguration.with {
dependencies.append(JvmDependency(files))
}.asSuccess()
}
}
private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilationConfiguration {
return ScriptCompilationConfiguration {
baseClass(SimpleScript::class)
defaultImports(DependsOn::class, Repository::class)
jvm {
val runtimeCp = runtimeClasspathFromJavaProperty()
updateClasspath((explicitCp ?: emptyList()) + runtimeCp)
}
refineConfiguration {
onAnnotations(DependsOn::class, Repository::class, handler = ::configureMavenDepsOnAnnotations)
}
}
}
private fun escapeKotlinString(value: String): String = buildString(value.length) {
value.forEach { ch ->
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}
private fun argsInitializer(args: List<String>): String =
"val args: Array<String> = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})"
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
private fun injectArgsDeclaration(scriptContent: String, args: List<String>): String {
val lines = scriptContent.lines()
val injected = argsInitializer(args)
var replaced = false
val result = lines.map { line ->
if (!replaced && argsDeclarationRegex.matches(line)) {
replaced = true
injected
} else {
line
}
}
return result.joinToString("\n")
}
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
return ScriptMetadata(description = description, params = params)
}
private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
private fun evalSource(source: SourceCode): ResultWithDiagnostics<EvaluationResult> {
val explicitCp = explicitClasspathFromEnv()
return scriptingHost.eval(source, compilationConfiguration(explicitCp), null)
}
data class ScriptExecutionResult(
val ok: Boolean,
val output: String,
val metadata: ScriptMetadata,
val missingRequiredParams: List<String>
)
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
fun removeCachedMetadata(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath)
}
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
synchronized(evalLock) {
val oldOut = System.out
val oldErr = System.err
val buffer = ByteArrayOutputStream()
val ps = PrintStream(buffer, true, Charsets.UTF_8.name())
return try {
System.setOut(ps)
System.setErr(ps)
val original = scriptFile.readText()
val metadata = metadataForFile(scriptFile, original)
val missingRequired = metadata.params
.filter { p ->
p.required && requestContext.args.none { token ->
token.substringBefore("=", missingDelimiterValue = "") == p.name
} && p.defaultValue == null
}
.map { it.name }
val injected = injectArgsDeclaration(original, requestContext.args)
val result = evalSource(injected.toScriptSource(scriptFile.name))
val diagnostics = result.reports
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
.joinToString("\n") {
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
"[${it.severity}] ${it.message}$ex"
}
val missingMessage = if (missingRequired.isEmpty()) "" else
"[ERROR] Missing required parameters: ${missingRequired.joinToString(", ")}"
val output = buffer.toString(Charsets.UTF_8.name()).trim()
val finalText = buildString {
if (output.isNotEmpty()) appendLine(output)
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
if (missingMessage.isNotEmpty()) appendLine(missingMessage)
}.trim()
ScriptExecutionResult(
ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(),
output = finalText,
metadata = metadata,
missingRequiredParams = missingRequired
)
} finally {
ps.flush()
ps.close()
System.setOut(oldOut)
System.setErr(oldErr)
}
}
}

View File

@@ -0,0 +1,18 @@
package work.slhaf.hub
data class ScriptParamDefinition(
val name: String,
val required: Boolean = false,
val defaultValue: String? = null,
val description: String? = null
)
data class ScriptMetadata(
val description: String? = null,
val params: List<ScriptParamDefinition> = emptyList()
)
data class ScriptRequestContext(
val args: List<String> = emptyList(),
val body: String? = null
)

View File

@@ -0,0 +1,6 @@
package work.slhaf.hub
import kotlin.script.experimental.annotations.KotlinScript
@KotlinScript(fileExtension = "hub.kts")
abstract class SimpleScript

View File

@@ -0,0 +1,125 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.routing
import java.io.File
private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val HOST = "127.0.0.1"
private fun Application.module(scriptsDir: File, apiToken: String) {
routing {
get("/health") {
call.respondText("OK")
}
get("/scripts") {
if (!requireAuth(call, apiToken)) return@get
call.respondText(renderScriptList(scriptsDir), ContentType.Text.Plain)
}
get("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@get
handleGetScriptContent(call, scriptsDir)
}
post("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@post
handleCreateScript(call, scriptsDir)
}
put("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@put
handleUpdateScript(call, scriptsDir)
}
delete("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@delete
handleDeleteScript(call, scriptsDir)
}
get("/meta/{script}") {
if (!requireAuth(call, apiToken)) return@get
val name = call.parameters["script"]
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val (metadata, source) = loadMetadata(script)
call.respondText(
metadataJson(name, metadata, source),
contentType = ContentType.Application.Json
)
}
get("/run/{script}") {
if (!requireAuth(call, apiToken)) return@get
handleRunRequest(call, scriptsDir, consumeBody = false)
}
post("/run/{script}") {
if (!requireAuth(call, apiToken)) return@post
handleRunRequest(call, scriptsDir, consumeBody = true)
}
}
}
private fun usage() {
println(
"""
Usage:
./gradlew runWeb --args='[--port=8080] [--scripts-dir=./scripts]'
Routes:
GET /health
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <token>
GET /scripts
GET /scripts/{script}
POST /scripts/{script}
PUT /scripts/{script}
DELETE /scripts/{script}
GET /meta/{script}
GET /run/{script}?k=v
POST /run/{script}?k=v
""".trimIndent()
)
}
private fun List<String>.optionValue(prefix: String): String? =
firstOrNull { it.startsWith(prefix) }?.substringAfter("=")
fun main(args: Array<String>) {
val cli = args.toList()
if ("--help" in cli || "-h" in cli) {
usage()
return
}
val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT
val scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile
if (!scriptsDir.exists()) scriptsDir.mkdirs()
val auth = loadOrCreateApiToken(scriptsDir)
println("Starting script web host on http://$HOST:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
println("Auth token source: ${auth.source}")
when {
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
auth.source.startsWith("generated:") ->
println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}")
else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}")
}
embeddedServer(Netty, port = port, host = HOST) {
module(scriptsDir, auth.token)
}.start(wait = true)
}

View File

@@ -0,0 +1,206 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import java.io.File
private const val SCRIPT_EXTENSION = ".hub.kts"
private val ROUTE_NAME_REGEX = Regex("[A-Za-z0-9._-]+")
fun resolveScriptFile(baseDir: File, routeName: String): File? {
if (!routeName.matches(ROUTE_NAME_REGEX)) return null
val canonicalBase = baseDir.canonicalFile
val candidate = File(baseDir, "$routeName$SCRIPT_EXTENSION").canonicalFile
val insideBase = candidate.path.startsWith(canonicalBase.path + File.separator)
return if (insideBase || candidate == canonicalBase) candidate else null
}
private fun listScriptNames(scriptsDir: File): List<String> =
scriptsDir.listFiles()
?.asSequence()
?.filter { it.isFile && it.name.endsWith(SCRIPT_EXTENSION) }
?.map { it.name.removeSuffix(SCRIPT_EXTENSION) }
?.sorted()
?.toList()
?: emptyList()
fun renderScriptList(scriptsDir: File): String =
listScriptNames(scriptsDir).joinToString("\n") { name ->
val file = resolveScriptFile(scriptsDir, name)
val description = file?.let(::cachedMetadata)?.description
if (description.isNullOrBlank()) name else "$name\t$description"
}
private fun String.jsonEscaped(): String = buildString(length) {
for (ch in this@jsonEscaped) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val params = metadata.params.joinToString(",") { param ->
val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}"""
}
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"params":[$params]}"""
}
fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
val cached = cachedMetadata(script)
if (cached != null) return cached to "cache"
val executed = evalAndCapture(script, ScriptRequestContext(args = emptyList())).metadata
return executed to "parsed"
}
suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (script.exists()) {
return call.respondText("script already exists: ${script.name}", status = HttpStatusCode.Conflict)
}
val content = call.receiveText()
if (content.isBlank()) {
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
}
script.parentFile?.mkdirs()
script.writeText(content)
removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext())
if (!result.ok) {
script.delete()
removeCachedMetadata(script)
return call.respondText(
"script validation failed:\n${result.output.ifBlank { "unknown error" }}",
status = HttpStatusCode.BadRequest,
contentType = ContentType.Text.Plain
)
}
call.respondText(
"created ${script.name}\n${result.output}".trim(),
status = HttpStatusCode.Created,
contentType = ContentType.Text.Plain
)
}
suspend fun handleDeleteScript(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val deleted = script.delete()
removeCachedMetadata(script)
if (!deleted) {
return call.respondText(
"failed to delete script: ${script.name}",
status = HttpStatusCode.InternalServerError
)
}
call.respondText("deleted ${script.name}", status = HttpStatusCode.OK)
}
suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val newContent = call.receiveText()
if (newContent.isBlank()) {
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
}
val previousContent = script.readText()
script.writeText(newContent)
removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext())
if (!result.ok) {
script.writeText(previousContent)
removeCachedMetadata(script)
return call.respondText(
"script validation failed, rolled back:\n${result.output.ifBlank { "unknown error" }}",
status = HttpStatusCode.BadRequest,
contentType = ContentType.Text.Plain
)
}
call.respondText(
"updated ${script.name}\n${result.output}".trim(),
status = HttpStatusCode.OK,
contentType = ContentType.Text.Plain
)
}
suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBody: Boolean) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val requestArgs = call.request.queryParameters.entries()
.mapNotNull { entry ->
val value = entry.value.lastOrNull() ?: return@mapNotNull null
"${entry.key}=$value"
}
.toList()
val requestBody = if (consumeBody) call.receiveText() else null
val result = evalAndCapture(script, ScriptRequestContext(args = requestArgs, body = requestBody))
val status = when {
result.ok -> HttpStatusCode.OK
result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest
else -> HttpStatusCode.InternalServerError
}
call.respondText(
result.output.ifBlank { if (result.ok) "OK" else "FAILED" },
status = status,
contentType = ContentType.Text.Plain
)
}
suspend fun handleGetScriptContent(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
call.respondText(script.readText(), contentType = ContentType.Text.Plain)
}

View File

@@ -0,0 +1,63 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respondText
import java.io.File
import java.security.SecureRandom
private const val ENV_API_TOKEN = "HOST_API_TOKEN"
private const val TOKEN_FILE_NAME = ".host-api-token"
private const val ALT_TOKEN_HEADER = "X-Host-Token"
data class ApiTokenConfig(
val token: String,
val source: String,
val tokenFile: File?
)
private fun randomTokenHex(bytes: Int = 32): String {
val random = ByteArray(bytes)
SecureRandom().nextBytes(random)
return random.joinToString("") { "%02x".format(it) }
}
fun loadOrCreateApiToken(scriptsDir: File): ApiTokenConfig {
val envToken = System.getenv(ENV_API_TOKEN)?.trim()
if (!envToken.isNullOrBlank()) {
return ApiTokenConfig(envToken, "env:$ENV_API_TOKEN", null)
}
val tokenFile = File(scriptsDir, TOKEN_FILE_NAME)
if (tokenFile.exists()) {
val saved = tokenFile.readText().trim()
if (saved.isNotBlank()) return ApiTokenConfig(saved, "file:${tokenFile.absolutePath}", tokenFile)
}
val token = randomTokenHex()
tokenFile.writeText(token)
tokenFile.setReadable(false, false)
tokenFile.setReadable(true, true)
tokenFile.setWritable(false, false)
tokenFile.setWritable(true, true)
return ApiTokenConfig(token, "generated:file:${tokenFile.absolutePath}", tokenFile)
}
private fun extractProvidedToken(call: ApplicationCall): String? {
val auth = call.request.headers[HttpHeaders.Authorization]
if (!auth.isNullOrBlank() && auth.startsWith("Bearer ", ignoreCase = true)) {
return auth.substringAfter("Bearer ").trim()
}
return call.request.headers[ALT_TOKEN_HEADER]?.trim()
}
suspend fun requireAuth(call: ApplicationCall, expectedToken: String): Boolean {
val provided = extractProvidedToken(call)
if (provided == expectedToken) return true
call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"")
call.respondText("unauthorized", status = HttpStatusCode.Unauthorized, contentType = ContentType.Text.Plain)
return false
}

258
tools/api-cli.kts Executable file
View File

@@ -0,0 +1,258 @@
#!/usr/bin/env kotlin
import java.io.File
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import kotlin.system.exitProcess
data class GlobalOptions(
val baseUrl: String,
val token: String?,
val tokenFile: String?,
)
data class ParsedInput(
val global: GlobalOptions,
val command: String,
val commandArgs: List<String>,
)
fun usage(): String =
"""
Usage:
elide run api-cli.main.kts [global options] <command> [command options]
Global options:
--base-url=<url> Default: http://127.0.0.1:8080
--token=<token> Authorization token
--token-file=<path> Load token from file (fallback: HOST_API_TOKEN env)
Commands:
health
list
show <script>
meta <script>
run <script> [--arg=k=v ...] [--body=text] [--post]
create <script> (--file=<path> | --text=<content>)
update <script> (--file=<path> | --text=<content>)
delete <script>
Examples:
elide run api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list
elide run api-cli.main.kts --token-file=./scripts/.host-api-token show hello
elide run api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true
elide run api-cli.main.kts --token-file=./scripts/.host-api-token create demo --file=./demo.hub.kts
""".trimIndent()
fun parseInput(args: List<String>): ParsedInput {
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
println(usage())
exitProcess(0)
}
var baseUrl = "http://127.0.0.1:8080"
var token: String? = null
var tokenFile: String? = null
var i = 0
while (i < args.size && args[i].startsWith("--")) {
val arg = args[i]
when {
arg.startsWith("--base-url=") -> baseUrl = arg.substringAfter("=")
arg.startsWith("--token=") -> token = arg.substringAfter("=")
arg.startsWith("--token-file=") -> tokenFile = arg.substringAfter("=")
else -> break
}
i++
}
if (i >= args.size) error("Missing command.\n${usage()}")
val command = args[i]
val commandArgs = args.drop(i + 1)
return ParsedInput(GlobalOptions(baseUrl.trimEnd('/'), token, tokenFile), command, commandArgs)
}
fun readToken(options: GlobalOptions): String? {
if (!options.token.isNullOrBlank()) return options.token
if (!options.tokenFile.isNullOrBlank()) {
val file = File(options.tokenFile)
if (!file.exists()) error("Token file not found: ${file.absolutePath}")
return file.readText().trim().ifBlank { null }
}
return System.getenv("HOST_API_TOKEN")?.trim()?.ifBlank { null }
}
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
fun requireScriptName(args: List<String>): String {
if (args.isEmpty()) error("Missing <script> argument.")
return args.first()
}
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
if (fileArg != null && textArg != null) error("Use either --file or --text, not both.")
if (fileArg == null && textArg == null) error("Missing --file or --text.")
return fileArg to textArg
}
fun parseRunArgs(args: List<String>): Triple<String, List<Pair<String, String>>, Pair<Boolean, String?>> {
val script = requireScriptName(args)
val rest = args.drop(1)
val query = mutableListOf<Pair<String, String>>()
var body: String? = null
var post = false
for (arg in rest) {
when {
arg == "--post" -> {
post = true
}
arg.startsWith("--body=") -> {
body = arg.substringAfter("=")
}
arg.startsWith("--arg=") -> {
val token = arg.substringAfter("--arg=")
val idx = token.indexOf('=')
if (idx <= 0) error("Invalid --arg format: $arg, expected --arg=key=value")
query += token.substring(0, idx) to token.substring(idx + 1)
}
else -> {
error("Unknown run option: $arg")
}
}
}
return Triple(script, query, post to body)
}
fun request(
client: HttpClient,
baseUrl: String,
token: String?,
method: String,
path: String,
body: String? = null,
): Pair<Int, String> {
val reqBuilder =
HttpRequest
.newBuilder(URI.create("$baseUrl$path"))
.header("Accept", "text/plain,application/json")
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
val request =
when (method) {
"GET" -> {
reqBuilder.GET().build()
}
"DELETE" -> {
reqBuilder.DELETE().build()
}
"POST" -> {
reqBuilder
.header("Content-Type", "text/plain; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body ?: ""))
.build()
}
"PUT" -> {
reqBuilder
.header("Content-Type", "text/plain; charset=utf-8")
.PUT(HttpRequest.BodyPublishers.ofString(body ?: ""))
.build()
}
else -> {
error("Unsupported method: $method")
}
}
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
return response.statusCode() to response.body()
}
fun main(args: Array<String>) {
try {
val input = parseInput(args.toList())
val token = readToken(input.global)
val client = HttpClient.newHttpClient()
val base = input.global.baseUrl
val (status, body) =
when (input.command) {
"health" -> {
request(client, base, null, "GET", "/health")
}
"list" -> {
request(client, base, token, "GET", "/scripts")
}
"show" -> {
val script = requireScriptName(input.commandArgs)
request(client, base, token, "GET", "/scripts/${encode(script)}")
}
"meta" -> {
val script = requireScriptName(input.commandArgs)
request(client, base, token, "GET", "/meta/${encode(script)}")
}
"run" -> {
val (script, queryPairs, postAndBody) = parseRunArgs(input.commandArgs)
val query =
if (queryPairs.isEmpty()) {
""
} else {
queryPairs.joinToString("&", prefix = "?") { (k, v) ->
"${encode(k)}=${encode(v)}"
}
}
val (post, postBody) = postAndBody
val method = if (post) "POST" else "GET"
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
}
"create" -> {
val script = requireScriptName(input.commandArgs)
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
request(client, base, token, "POST", "/scripts/${encode(script)}", bodyContent)
}
"update" -> {
val script = requireScriptName(input.commandArgs)
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
request(client, base, token, "PUT", "/scripts/${encode(script)}", bodyContent)
}
"delete" -> {
val script = requireScriptName(input.commandArgs)
request(client, base, token, "DELETE", "/scripts/${encode(script)}")
}
else -> {
error("Unknown command: ${input.command}\n${usage()}")
}
}
println(body)
if (status >= 400) {
System.err.println("[HTTP $status]")
exitProcess(1)
}
} catch (e: Throwable) {
System.err.println("Error: ${e.message}")
exitProcess(1)
}
}
main(args)

207
tools/api-cli.main.kts Executable file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env kotlin
import java.io.File
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import kotlin.system.exitProcess
data class GlobalOptions(
val baseUrl: String,
val token: String?,
val tokenFile: String?
)
data class ParsedInput(
val global: GlobalOptions,
val command: String,
val commandArgs: List<String>
)
fun usage(): String = """
Usage:
elide run api-cli.main.kts [global options] <command> [command options]
Global options:
--base-url=<url> Default: http://127.0.0.1:8080
--token=<token> Authorization token
--token-file=<path> Load token from file (fallback: HOST_API_TOKEN env)
Commands:
health
list
show <script>
meta <script>
run <script> [--arg=k=v ...] [--body=text] [--post]
create <script> (--file=<path> | --text=<content>)
update <script> (--file=<path> | --text=<content>)
delete <script>
Examples:
elide run api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list
elide run api-cli.main.kts --token-file=./scripts/.host-api-token show hello
elide run api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true
elide run api-cli.main.kts --token-file=./scripts/.host-api-token create demo --file=./demo.hub.kts
""".trimIndent()
fun parseInput(args: List<String>): ParsedInput {
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
println(usage())
exitProcess(0)
}
var baseUrl = "http://127.0.0.1:8080"
var token: String? = null
var tokenFile: String? = null
var i = 0
while (i < args.size && args[i].startsWith("--")) {
val arg = args[i]
when {
arg.startsWith("--base-url=") -> baseUrl = arg.substringAfter("=")
arg.startsWith("--token=") -> token = arg.substringAfter("=")
arg.startsWith("--token-file=") -> tokenFile = arg.substringAfter("=")
else -> break
}
i++
}
if (i >= args.size) error("Missing command.\n${usage()}")
val command = args[i]
val commandArgs = args.drop(i + 1)
return ParsedInput(GlobalOptions(baseUrl.trimEnd('/'), token, tokenFile), command, commandArgs)
}
fun readToken(options: GlobalOptions): String? {
if (!options.token.isNullOrBlank()) return options.token
if (!options.tokenFile.isNullOrBlank()) {
val file = File(options.tokenFile)
if (!file.exists()) error("Token file not found: ${file.absolutePath}")
return file.readText().trim().ifBlank { null }
}
return System.getenv("HOST_API_TOKEN")?.trim()?.ifBlank { null }
}
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
fun requireScriptName(args: List<String>): String {
if (args.isEmpty()) error("Missing <script> argument.")
return args.first()
}
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
if (fileArg != null && textArg != null) error("Use either --file or --text, not both.")
if (fileArg == null && textArg == null) error("Missing --file or --text.")
return fileArg to textArg
}
fun parseRunArgs(args: List<String>): Triple<String, List<Pair<String, String>>, Pair<Boolean, String?>> {
val script = requireScriptName(args)
val rest = args.drop(1)
val query = mutableListOf<Pair<String, String>>()
var body: String? = null
var post = false
for (arg in rest) {
when {
arg == "--post" -> post = true
arg.startsWith("--body=") -> body = arg.substringAfter("=")
arg.startsWith("--arg=") -> {
val token = arg.substringAfter("--arg=")
val idx = token.indexOf('=')
if (idx <= 0) error("Invalid --arg format: $arg, expected --arg=key=value")
query += token.substring(0, idx) to token.substring(idx + 1)
}
else -> error("Unknown run option: $arg")
}
}
return Triple(script, query, post to body)
}
fun request(
client: HttpClient,
baseUrl: String,
token: String?,
method: String,
path: String,
body: String? = null
): Pair<Int, String> {
val reqBuilder = HttpRequest.newBuilder(URI.create("$baseUrl$path"))
.header("Accept", "text/plain,application/json")
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
val request = when (method) {
"GET" -> reqBuilder.GET().build()
"DELETE" -> reqBuilder.DELETE().build()
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
else -> error("Unsupported method: $method")
}
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
return response.statusCode() to response.body()
}
fun main(args: Array<String>) {
try {
val input = parseInput(args.toList())
val token = readToken(input.global)
val client = HttpClient.newHttpClient()
val base = input.global.baseUrl
val (status, body) = when (input.command) {
"health" -> request(client, base, null, "GET", "/health")
"list" -> request(client, base, token, "GET", "/scripts")
"show" -> {
val script = requireScriptName(input.commandArgs)
request(client, base, token, "GET", "/scripts/${encode(script)}")
}
"meta" -> {
val script = requireScriptName(input.commandArgs)
request(client, base, token, "GET", "/meta/${encode(script)}")
}
"run" -> {
val (script, queryPairs, postAndBody) = parseRunArgs(input.commandArgs)
val query = if (queryPairs.isEmpty()) "" else queryPairs.joinToString("&", prefix = "?") { (k, v) ->
"${encode(k)}=${encode(v)}"
}
val (post, postBody) = postAndBody
val method = if (post) "POST" else "GET"
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
}
"create" -> {
val script = requireScriptName(input.commandArgs)
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
request(client, base, token, "POST", "/scripts/${encode(script)}", bodyContent)
}
"update" -> {
val script = requireScriptName(input.commandArgs)
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
request(client, base, token, "PUT", "/scripts/${encode(script)}", bodyContent)
}
"delete" -> {
val script = requireScriptName(input.commandArgs)
request(client, base, token, "DELETE", "/scripts/${encode(script)}")
}
else -> error("Unknown command: ${input.command}\n${usage()}")
}
println(body)
if (status >= 400) {
System.err.println("[HTTP $status]")
exitProcess(1)
}
} catch (e: Throwable) {
System.err.println("Error: ${e.message}")
exitProcess(1)
}
}
main(args)

536
tools/api-tui.main.kts Normal file
View File

@@ -0,0 +1,536 @@
#!/usr/bin/env kotlin
import java.io.File
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import kotlin.math.max
import kotlin.system.exitProcess
data class Options(
val baseUrl: String,
val token: String?,
val tokenFile: String?
)
private val RESET = "\u001b[0m"
private val BOLD = "\u001b[1m"
private val DIM = "\u001b[2m"
private val CYAN = "\u001b[36m"
private val GREEN = "\u001b[32m"
private val YELLOW = "\u001b[33m"
private val RED = "\u001b[31m"
private val BG_BLUE = "\u001b[44m"
private val FG_BLACK = "\u001b[30m"
fun ok(text: String) = "$GREEN$text$RESET"
fun warn(text: String) = "$YELLOW$text$RESET"
fun err(text: String) = "$RED$text$RESET"
fun accent(text: String) = "$CYAN$text$RESET"
fun selected(text: String) = "$BG_BLUE$FG_BLACK$BOLD$text$RESET"
fun usage(): String = """
Usage:
kotlin tools/api-tui.main.kts [--base-url=http://127.0.0.1:8080] [--token=<token> | --token-file=./scripts/.host-api-token]
Keys:
Up/Down or j/k Select script
Left/Right or h/l Select action
Enter Execute action
q Quit
""".trimIndent()
fun parseOptions(args: List<String>): Options {
if (args.contains("--help") || args.contains("-h")) {
println(usage())
exitProcess(0)
}
var baseUrl = "http://127.0.0.1:8080"
var token: String? = null
var tokenFile: String? = null
args.forEach { arg ->
when {
arg.startsWith("--base-url=") -> baseUrl = arg.substringAfter("=")
arg.startsWith("--token=") -> token = arg.substringAfter("=")
arg.startsWith("--token-file=") -> tokenFile = arg.substringAfter("=")
else -> error("Unknown option: $arg\n${usage()}")
}
}
return Options(baseUrl.trimEnd('/'), token, tokenFile)
}
fun readToken(options: Options): String {
if (!options.token.isNullOrBlank()) return options.token
if (!options.tokenFile.isNullOrBlank()) {
val file = File(options.tokenFile)
if (!file.exists()) error("Token file not found: ${file.absolutePath}")
return file.readText().trim()
}
return System.getenv("HOST_API_TOKEN")?.trim()
?: error("Missing token. Use --token or --token-file or HOST_API_TOKEN")
}
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
fun request(
client: HttpClient,
baseUrl: String,
token: String,
method: String,
path: String,
body: String? = null
): Pair<Int, String> {
val reqBuilder = HttpRequest.newBuilder(URI.create("$baseUrl$path"))
.header("Accept", "text/plain,application/json")
.header("Authorization", "Bearer $token")
val request = when (method) {
"GET" -> reqBuilder.GET().build()
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
"DELETE" -> reqBuilder.DELETE().build()
else -> error("Unsupported method: $method")
}
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
return response.statusCode() to response.body()
}
fun shell(cmd: String): String {
val p = ProcessBuilder("bash", "-lc", cmd).redirectErrorStream(true).start()
val out = p.inputStream.bufferedReader().readText()
p.waitFor()
return out.trim()
}
fun commandExists(cmd: String): Boolean =
ProcessBuilder("bash", "-lc", "command -v $cmd >/dev/null 2>&1").start().waitFor() == 0
enum class Key {
UP, DOWN, LEFT, RIGHT, ENTER, Q, OTHER
}
data class RunProfile(
val method: String = "GET",
val queryRaw: String = "",
val body: String = ""
)
fun readKey(): Key {
val first = System.`in`.read()
if (first == -1) return Key.OTHER
return when (first) {
10, 13 -> Key.ENTER
'q'.code, 'Q'.code -> Key.Q
'k'.code, 'K'.code -> Key.UP
'j'.code, 'J'.code -> Key.DOWN
'h'.code, 'H'.code -> Key.LEFT
'l'.code, 'L'.code -> Key.RIGHT
27 -> {
val second = System.`in`.read()
val third = System.`in`.read()
if (second == '['.code) {
when (third) {
'A'.code -> Key.UP
'B'.code -> Key.DOWN
'C'.code -> Key.RIGHT
'D'.code -> Key.LEFT
else -> Key.OTHER
}
} else Key.OTHER
}
else -> Key.OTHER
}
}
fun clearScreen() {
print("\u001b[2J\u001b[H")
}
fun drawRunConfig(scriptName: String, profile: RunProfile, selected: Int, hint: String) {
clearScreen()
println("${accent("Run Config")} ${DIM}script=$scriptName$RESET")
println("${DIM}Up/Down select | Left/Right toggle | Enter edit/execute | q cancel$RESET")
println()
val rows = listOf(
"Method: ${profile.method}",
"Query: ${profile.queryRaw.ifBlank { "(empty)" }}",
"Body: ${if (profile.method == "POST") profile.body.ifBlank { "(empty)" } else "(ignored for GET)"}",
"Execute",
"Cancel"
)
rows.forEachIndexed { idx, row ->
if (idx == selected) println(" ${selected("> $row")}") else println(" $row")
}
println()
println("${BOLD}Hint:$RESET ${colorizeStatusLine(hint)}")
}
fun colorizeStatusLine(line: String): String {
return when {
line.startsWith("[ERROR]") || line.startsWith("[HTTP 4") || line.startsWith("[HTTP 5") -> err(line)
line.startsWith("Loaded") || line.contains("HTTP 200") || line.startsWith("[RUN") || line.startsWith("[SHOW") || line.startsWith("[META") || line.startsWith("[CREATE") || line.startsWith("[EDIT") || line.startsWith("[DELETE") -> ok(line)
line.startsWith("No scripts.") || line.startsWith("[HTTP 3") || line.startsWith("[CANCEL]") -> warn(line)
else -> line
}
}
fun draw(
baseUrl: String,
scripts: List<String>,
selectedScript: Int,
actions: List<String>,
selectedAction: Int,
output: String
) {
clearScreen()
println("${accent("API TUI")} ${DIM}base=$baseUrl$RESET")
println("${DIM}Keys: Up/Down/j/k script | Left/Right/h/l action | Enter execute | q quit$RESET")
println()
print("${BOLD}Actions:$RESET ")
actions.forEachIndexed { idx, name ->
if (idx == selectedAction) print(selected(" $name ")) else print("[${accent(name)}] ")
}
println()
println()
println("${BOLD}Scripts:$RESET")
if (scripts.isEmpty()) {
println(" ${DIM}(no scripts)$RESET")
} else {
scripts.forEachIndexed { idx, name ->
if (idx == selectedScript) {
println(" ${selected("> $name")}")
} else {
println(" $name")
}
}
}
println()
println("${BOLD}Output:$RESET")
val lines = output.lines()
lines.takeLast(max(1, 16)).forEach { println(colorizeStatusLine(it)) }
}
fun fetchScripts(client: HttpClient, baseUrl: String, token: String): Pair<List<String>, String> {
return try {
val (status, body) = request(client, baseUrl, token, "GET", "/scripts")
if (status >= 400) return emptyList<String>() to "[HTTP $status]\n$body"
val scripts = body.lines().map { it.trim() }.filter { it.isNotBlank() }.map { it.substringBefore('\t') }
scripts to "Loaded ${scripts.size} script(s)."
} catch (t: Throwable) {
emptyList<String>() to "[ERROR] ${t::class.simpleName}: ${t.message}"
}
}
fun chooseEditor(): String? {
val env = System.getenv("EDITOR")?.trim()
if (!env.isNullOrBlank()) return env
val fallback = listOf("nvim", "vim", "nano").firstOrNull { commandExists(it) }
return fallback
}
fun promptLine(oldStty: String, prompt: String): String {
shell("stty $oldStty < /dev/tty")
print("\u001b[?25h")
print(prompt)
val value = readLine().orEmpty()
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
print("\u001b[?25l")
return value.trim()
}
fun promptRawLine(oldStty: String, prompt: String): String {
shell("stty $oldStty < /dev/tty")
print("\u001b[?25h")
print(prompt)
val value = readLine().orEmpty()
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
print("\u001b[?25l")
return value
}
fun openEditor(oldStty: String, file: File): Pair<Boolean, String> {
val editor = chooseEditor() ?: return false to "[ERROR] No editor found (set EDITOR or install nvim/vim/nano)."
shell("stty $oldStty < /dev/tty")
print("\u001b[?25h")
println("Opening editor: $editor ${file.absolutePath}")
val cmd = """$editor "${file.absolutePath.replace("\"", "\\\"")}""""
val process = ProcessBuilder("bash", "-lc", cmd).inheritIO().start()
val code = process.waitFor()
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
print("\u001b[?25l")
return if (code == 0) true to "[OK] Editor closed." else false to "[ERROR] Editor exited with code $code"
}
fun initialScriptTemplate(name: String): String = """
// @desc: $name
// @param: sample | default=value | desc=example parameter
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("script=$name")
println("sample=" + (kv["sample"] ?: "value"))
""".trimIndent()
fun buildQueryString(raw: String): String {
val items = raw.split(Regex("[&\\s]+")).map { it.trim() }.filter { it.isNotBlank() }
if (items.isEmpty()) return ""
val pairs = items.mapNotNull {
val idx = it.indexOf('=')
if (idx <= 0) null else it.substring(0, idx) to it.substring(idx + 1)
}
if (pairs.isEmpty()) return ""
return pairs.joinToString("&", prefix = "?") { (k, v) -> "${encode(k)}=${encode(v)}" }
}
fun runCreateFlow(
client: HttpClient,
baseUrl: String,
token: String,
oldStty: String
): String {
val scriptName = promptLine(oldStty, "Create script name: ")
if (scriptName.isBlank()) return "[CANCEL] Empty script name."
val sourceMode = promptLine(oldStty, "Source mode [e=editor,f=file] (default e): ").lowercase().ifBlank { "e" }
val content = when (sourceMode) {
"f" -> {
val path = promptLine(oldStty, "Source file path: ")
if (path.isBlank()) return "[CANCEL] Empty file path."
val f = File(path)
if (!f.exists()) return "[ERROR] File not found: ${f.absolutePath}"
f.readText()
}
else -> {
val temp = File.createTempFile("apis-create-$scriptName-", ".hub.kts")
val template = initialScriptTemplate(scriptName)
temp.writeText(template)
val (ok, msg) = openEditor(oldStty, temp)
if (!ok) {
temp.delete()
return msg
}
val text = temp.readText()
temp.delete()
if (text == template) return "[CANCEL] No changes in template, skipped create."
text
}
}
if (content.isBlank()) return "[CANCEL] Empty script content."
val (status, body) = request(client, baseUrl, token, "POST", "/scripts/${encode(scriptName)}", content)
return "[CREATE $scriptName] HTTP $status\n$body"
}
fun runEditFlow(
client: HttpClient,
baseUrl: String,
token: String,
scriptName: String,
oldStty: String
): String {
val (statusGet, bodyGet) = request(client, baseUrl, token, "GET", "/scripts/${encode(scriptName)}")
if (statusGet >= 400) return "[EDIT $scriptName] HTTP $statusGet\n$bodyGet"
val temp = File.createTempFile("apis-edit-$scriptName-", ".hub.kts")
temp.writeText(bodyGet)
val (ok, msg) = openEditor(oldStty, temp)
if (!ok) {
temp.delete()
return msg
}
val edited = temp.readText()
temp.delete()
if (edited == bodyGet) return "[CANCEL] No changes for $scriptName."
val (statusPut, bodyPut) = request(client, baseUrl, token, "PUT", "/scripts/${encode(scriptName)}", edited)
return "[EDIT $scriptName] HTTP $statusPut\n$bodyPut"
}
fun runDeleteFlow(
client: HttpClient,
baseUrl: String,
token: String,
scriptName: String,
oldStty: String
): String {
val confirm = promptLine(oldStty, "Delete '$scriptName'? [y/N]: ").lowercase()
if (confirm != "y" && confirm != "yes") return "[CANCEL] Delete aborted."
val (status, body) = request(client, baseUrl, token, "DELETE", "/scripts/${encode(scriptName)}")
return "[DELETE $scriptName] HTTP $status\n$body"
}
fun runScriptFlow(
client: HttpClient,
baseUrl: String,
token: String,
scriptName: String,
oldStty: String,
initialProfile: RunProfile
): Pair<String, RunProfile?> {
var profile = initialProfile
var selected = 0
var hint = "Configure request and choose Execute."
while (true) {
drawRunConfig(scriptName, profile, selected, hint)
when (readKey()) {
Key.UP -> selected = if (selected == 0) 4 else selected - 1
Key.DOWN -> selected = if (selected == 4) 0 else selected + 1
Key.LEFT, Key.RIGHT -> {
if (selected == 0) {
profile = profile.copy(method = if (profile.method == "GET") "POST" else "GET")
hint = "Method set to ${profile.method}"
}
}
Key.Q -> return "[CANCEL] Run aborted." to null
Key.ENTER -> {
when (selected) {
0 -> {
profile = profile.copy(method = if (profile.method == "GET") "POST" else "GET")
hint = "Method set to ${profile.method}"
}
1 -> {
val input = promptRawLine(oldStty, "Query args (k=v separated by '&' or space): ")
profile = profile.copy(queryRaw = input.trim())
hint = "Query updated."
}
2 -> {
if (profile.method == "POST") {
val input = promptRawLine(oldStty, "POST body (single line, blank allowed): ")
profile = profile.copy(body = input)
hint = "Body updated."
} else {
hint = "Body is ignored for GET."
}
}
3 -> {
val query = buildQueryString(profile.queryRaw)
val body = if (profile.method == "POST") profile.body else null
val (status, response) = request(client, baseUrl, token, profile.method, "/run/${encode(scriptName)}$query", body)
return "[RUN $scriptName] HTTP $status\n$response" to profile
}
4 -> return "[CANCEL] Run aborted." to null
}
}
else -> {}
}
}
}
fun main(args: Array<String>) {
val options = parseOptions(args.toList())
val token = readToken(options)
val client = HttpClient.newHttpClient()
val actions = listOf("Refresh", "Show", "Run", "Meta", "Create", "Edit", "Delete", "Quit")
val runProfiles = mutableMapOf<String, RunProfile>()
var selectedAction = 0
var selectedScript = 0
var output = ""
val oldStty = shell("stty -g < /dev/tty")
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
print("\u001b[?25l")
try {
var (scripts, initMessage) = fetchScripts(client, options.baseUrl, token)
output = initMessage
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
while (true) {
draw(options.baseUrl, scripts, selectedScript, actions, selectedAction, output)
when (readKey()) {
Key.UP -> if (scripts.isNotEmpty()) selectedScript = max(0, selectedScript - 1)
Key.DOWN -> if (scripts.isNotEmpty()) selectedScript = minOf(scripts.lastIndex, selectedScript + 1)
Key.LEFT -> selectedAction = if (selectedAction == 0) actions.lastIndex else selectedAction - 1
Key.RIGHT -> selectedAction = if (selectedAction == actions.lastIndex) 0 else selectedAction + 1
Key.Q -> break
Key.ENTER -> {
when (actions[selectedAction]) {
"Refresh" -> {
val result = fetchScripts(client, options.baseUrl, token)
scripts = result.first
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
output = result.second
}
"Show" -> {
output = if (scripts.isEmpty()) {
"No scripts."
} else runCatching {
val script = scripts[selectedScript]
val (status, body) = request(client, options.baseUrl, token, "GET", "/scripts/${encode(script)}")
"[SHOW $script] HTTP $status\n$body"
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
}
"Run" -> {
output = if (scripts.isEmpty()) {
"No scripts."
} else runCatching {
val script = scripts[selectedScript]
val initial = runProfiles[script] ?: RunProfile()
val (text, updated) = runScriptFlow(client, options.baseUrl, token, script, oldStty, initial)
if (updated != null) runProfiles[script] = updated
text
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
}
"Meta" -> {
output = if (scripts.isEmpty()) {
"No scripts."
} else runCatching {
val script = scripts[selectedScript]
val (status, body) = request(client, options.baseUrl, token, "GET", "/meta/${encode(script)}")
"[META $script] HTTP $status\n$body"
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
}
"Create" -> {
output = runCatching { runCreateFlow(client, options.baseUrl, token, oldStty) }
.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
val refreshed = fetchScripts(client, options.baseUrl, token)
scripts = refreshed.first
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
}
"Edit" -> {
output = if (scripts.isEmpty()) {
"No scripts."
} else runCatching {
val script = scripts[selectedScript]
runEditFlow(client, options.baseUrl, token, script, oldStty)
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
val refreshed = fetchScripts(client, options.baseUrl, token)
scripts = refreshed.first
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
}
"Delete" -> {
output = if (scripts.isEmpty()) {
"No scripts."
} else runCatching {
val script = scripts[selectedScript]
runDeleteFlow(client, options.baseUrl, token, script, oldStty)
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
val refreshed = fetchScripts(client, options.baseUrl, token)
scripts = refreshed.first
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
}
"Quit" -> break
}
}
else -> {}
}
}
} finally {
shell("stty $oldStty < /dev/tty")
print("\u001b[?25h")
clearScreen()
}
}
main(args)