LucidLink Admin Assistant
Desktop appA desktop app for LucidLink administrators. Manage users, groups, and permissions from a simple interface, and run Python scripts to automate your own workflows.
Scripts let you add your own automation to the Admin Assistant. Each script is a self-contained folder that appears automatically in the Scripts section of the Tools page, and can also be run on its own from the terminal.
Script folder structure
my-script/├── plugin.json ← required: manifest (name, description, version, inputs)├── plugin.py ← required: the script (filename can be overridden via "script")└── README.md ← optional: shown in the app's Script Info panel
Drop the folder into your scripts directory (Settings → Scripts Directory, default ~/) and click Reload Scripts.
Scripts included with the app are copied into your scripts directory on first launch, with the same folder names, plugin.json, and README.md intact.
plugin.json - the manifest
{"name": "My Tool","description": "A short description shown on the tool card.","version": "1.0","author": "Your Name","inputs": [{"id": "inputId","label": "Display Label","type": "text","required": true,"default": "some default","placeholder": "hint text"}]}
Input types
type | Description | UI element |
|---|---|---|
| Plain text | Text input |
| Secret value | Masked input |
| Directory path | Text input + Browse button (native folder picker) |
| File path | Text input + Browse button (native file picker) |
| Fixed choices | Dropdown |
| Auto-injected, not shown in form | None |
text
Description
Plain text
UI element
Text input
password
Description
Secret value
UI element
Masked input
folder
Description
Directory path
UI element
Text input + Browse button (native folder picker)
file
Description
File path
UI element
Text input + Browse button (native file picker)
select
Description
Fixed choices
UI element
Dropdown
filespace
Description
Auto-injected, not shown in form
UI element
None
For select inputs, include an options array:
{"id": "format","label": "Output Format","type": "select","options": [{ "value": "csv", "label": "CSV" },{ "value": "json", "label": "JSON" }]}
The filespace type signals that your script uses the currently connected filespace. The app injects --filespace-id automatically, so you do not need to include this input in your form, just reference it in your script.
plugin.py - the script
How the app calls your script
When someone runs your script from the app, the bearer token and every manifest input are written to your script's stdin as a single JSON object. argv only carries non-sensitive operational context:
# What the app actually runs:python plugin.py \--base-url http://localhost:3003/api/v1 \--filespace-id <filespace-id>
{"schemaVersion": 1,"token": "<bearer-token>","baseUrl": "http://localhost:3003/api/v1","filespaceId": "<filespace-id>","<inputId1>": "<value1>","<inputId2>": "<value2>"}
The token and every manifest input are delivered over stdin only, never on argv. argv is visible to any same-uid process via ps or / for the entire run, which may last hours, so secrets and user-supplied input values must not appear there.
Manifest input IDs are camelCase JSON keys (matching plugin.json → inputs[].id). When you also expose them as argparse flags for standalone use, follow the camelCase → kebab-case convention:
outputDir→--output-dirfromDate→--from-dateadminPassword→--admin-password
Standalone (interactive) mode
When run directly without arguments from a TTY, your script falls back to argparse plus interactive prompts. The recommended pattern below handles both paths.
python plugin.py # interactive prompts for token, filespace, inputspython plugin.py --help # shows usagepython plugin.py --token X --base-url Y --filespace-id Z # argv override
Minimum boilerplate
#!/usr/bin/env python3import argparseimport getpassimport jsonimport sysdef _read_runner_input():"""Read the JSON payload the Admin Assistant writes to stdin.Returns {} for standalone CLI use (TTY) so argparse on argv still works."""try:if sys.stdin.isatty():return {}except (ValueError, OSError):return {}try:raw = sys.stdin.read()except (OSError, ValueError):return {}if not raw:return {}try:payload = json.loads(raw)except json.JSONDecodeError:return {}return payload if isinstance(payload, dict) else {}def main():runner = _read_runner_input()parser = argparse.ArgumentParser(description="My script.")parser.add_argument("--base-url", default=runner.get("baseUrl") or "http://localhost:3003/api/v1")parser.add_argument("--token", default=runner.get("token"))parser.add_argument("--filespace-id", default=runner.get("filespaceId"))# Add your manifest inputs. Camel-case → kebab-case; default reads from# the runner payload first, then falls back to a hardcoded default.parser.add_argument("--output-dir", default=runner.get("outputDir") or "~/Desktop")args = parser.parse_args()# Fall back to interactive prompts if values are still missing (standalone use).token = args.tokenif not token:token = getpass.getpass("Enter API Bearer token: ").strip()if not token:print("Error: token is required.", file=sys.stderr)sys.exit(1)# Your logic here ...print("[1/3] Starting...")print("[2/3] Processing...")print("[3/3] Done!")if __name__ == "__main__":main()
Alternative: stdin-first scripts (no argparse for inputs)
If your script only runs from the app and you do not need standalone CLI ergonomics, you can read the JSON payload directly and skip argparse for the input fields. The rekey scripts follow this pattern:
raw = sys.stdin.read().strip()try:args = json.loads(raw) if raw else {}except json.JSONDecodeError:args = {}target_filespace = args.get("targetFilespace", "").strip()access_key = args.get("accessKey", "").strip()# ...
Progress output
The app streams your script's stdout line by line into a live log window. Print progress as you go:
print(f"[{i}/{total}] Processing {item}")print("DONE: 42 items exported → ~/Desktop/output.csv")
Lines printed to stderr appear in the log with a red indicator, which is useful for warnings and errors that should not stop the script.
Exit codes:
0→ success (green "Completed successfully" banner)- Non-zero → failure (red "Exited with code N" banner)
Receiving credentials from the app
The app passes --base-url and --filespace-id on argv. It does not pass `--token` on argv. The bearer token arrives over stdin as part of the runner JSON payload, so a same-uid process cannot read it out of ps or / while a long migration runs.
Read the token from the runner payload, exactly as the main() skeleton above does, and keep --token as an argv fallback for standalone use:
parser.add_argument("--base-url") # None when run standalone — prompt or use defaultparser.add_argument("--token", default=runner.get("token")) # stdin first; None standalone — prompt with getpassparser.add_argument("--filespace-id") # None when run standalone — list and prompt
Detect whether you are in app mode or standalone:
app_mode = bool(args.base_url and args.token and args.filespace_id)if not app_mode:# Interactive prompts...else:# Use injected values directly...
Third-party dependencies
If your script needs packages beyond the Python standard library (for example requests or pydantic):
Add a `requirements.txt` to your script folder
requests>=2.31Document the install step
Users must run
pip install -r requirements.txtthemselves.Prefer the standard library where you can
Using
urllibavoids the dependency entirely.
The app ships no Python environment. It uses whatever python3 or python is resolved from a fixed list of trusted install prefixes.
Example: minimal script
`hello-world/plugin.json`
{"name": "Hello World","description": "Prints filespace info to stdout.","version": "1.0","inputs": [{"id": "greeting","label": "Greeting","type": "text","required": false,"default": "Hello","placeholder": "Hello"}]}
`hello-world/plugin.py`
#!/usr/bin/env python3import argparseimport jsonimport urllib.requestdef api_get(base_url, token, path):req = urllib.request.Request(f"{base_url}{path}",headers={"accept": "application/json", "Authorization": f"Bearer {token}"},)with urllib.request.urlopen(req) as resp:return json.loads(resp.read())def main():parser = argparse.ArgumentParser()parser.add_argument("--base-url", default="http://localhost:3003/api/v1")parser.add_argument("--token")parser.add_argument("--filespace-id")parser.add_argument("--greeting", default="Hello")args = parser.parse_args()import getpasstoken = args.token or getpass.getpass("Token: ")base_url = args.base_url or "http://localhost:3003/api/v1"print(f"{args.greeting} from my script!")if args.filespace_id:resp = api_get(base_url, token, "/filespaces")filespaces = resp.get("data", [])for fs in filespaces:if fs.get("id") == args.filespace_id:print(f"Connected filespace: {fs['name']}")print("DONE")if __name__ == "__main__":main()
Integrity: what the app checks at launch
Every published artifact carries a SHA-256 sidecar (<artifact>.sha256, a single lowercase hex line). The versions manifest stamps that hash onto each asset, and the download UI refuses to render a download button for an asset with no hash.
Scripts should also ship a manifest signature (plugin.json.sig, ECDSA P-256). The manifest then lists a files array binding each file in the script folder to its SHA-256:
{"name": "Example Script","version": "1.2","inputs": [ /* … */ ],"script": "plugin.py","files": [{ "path": "plugin.py", "sha256": "<hex>" },{ "path": "requirements.txt", "sha256": "<hex>" }]}
Each time a script starts, the Admin Assistant verifies:
Manifest signature
ECDSA over the
plugin.jsonbytes, against the public key embedded in the app.Per-file hashes
For every entry in
files[], the on-disk SHA-256 must match the declared hex.
If you are packaging a script for distribution, generate the files array and the signature as part of your release step so both checks can pass.
Checklist before distributing a script
plugin.json has name, description, version, and inputs
plugin.py uses argparse and handles missing args gracefully
Script exits 0 on success, non-zero on failure
Progress lines are printed to stdout
Errors are printed to stderr (or as regular progress lines prefixed with ERROR:)
Tested standalone: python plugin.py (interactive)
Tested with args: python plugin.py --base-url ... --token ... --filespace-id ...
files[] and plugin.json.sig generated, if you publish the script
(Optional) README.md in the script folder for in-app help