LLeMbas CLI 1.0.0
ci / check (push) Waiting to run

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
// Lifted from OpenCode packages/opencode/src/permission/arity.ts (MIT, © 2025 opencode).
import SPEC from "../../harness/permission/arity.json"
// The human-meaningful prefix of a command, used to build "always allow" patterns: `git commit -m x` → `git commit`.
export function prefix(tokens: string[]) {
// Options before the subcommand do not count ("options never count", in the table's rules): `git -C dir
// commit` is `git commit` — and an "always" for `git -C *` would cover every git command.
if (tokens.length > 1 && tokens.slice(1).some((t) => t.startsWith("-")) && ARITY[tokens[0]!] !== undefined && ARITY[tokens[0]!]! > 1 && tokens[1]!.startsWith("-")) return tokens
for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
// The table is the harness spec's (harness/permission/arity.json), shared with LLeMbas. OpenCode
// generated it with a prompt; its rules: each entry maps a command prefix to how many words define
// the command, options never count, the longest matching prefix wins.
const ARITY: Record<string, number> = SPEC.arity
+146
View File
@@ -0,0 +1,146 @@
// A deliberately small shell reader: enough to split a command line into the simple commands
// it runs and to know when it cannot be sure. It never needs to be a full parser, because every
// case it does not understand falls back to asking.
export interface Split {
/** Each simple command, trimmed, in order. */
commands: string[]
/** Why an `allow` rule may not be trusted for this line (command substitution, eval, redirection
* to a file…). Empty when the split is clean. */
unsafe: string[]
}
const SAFE_REDIRECT = /^(\d?>&\d|\d?>\s*\/dev\/null|&>\s*\/dev\/null)$/
export function splitCommand(line: string): Split {
const commands: string[] = []
const unsafe = new Set<string>()
let cur = ""
let quote: "'" | '"' | null = null
const flush = () => {
const c = cur.trim()
if (c) commands.push(c)
cur = ""
}
for (let i = 0; i < line.length; i++) {
const ch = line[i]!
const next = line[i + 1]
if (quote === "'") {
cur += ch
if (ch === "'") quote = null
// A quoted string over several lines is harmless to bash, but it is how a line's real shape
// gets hidden from a reader like this one: not trusted.
else if (ch === "\n") unsafe.add("a quoted string across lines")
continue
}
if (ch === "\\" && next !== undefined) {
cur += ch + next
i++
continue
}
if (quote === '"') {
cur += ch
if (ch === '"') quote = null
else if (ch === "`" || (ch === "$" && next === "(")) unsafe.add("command substitution")
else if (ch === "\n") unsafe.add("a quoted string across lines")
continue
}
// A comment runs to the end of the line, and bash reads nothing in it — a quote in a comment
// must not open a quote here (it would hide the next line's command inside one).
if (ch === "#" && (cur === "" || /\s$/.test(cur))) {
while (i + 1 < line.length && line[i + 1] !== "\n") i++
continue
}
// Quoting and expansion this reader does not follow: ANSI-C $'…' (backslash escapes a quote
// there), ${…}, and here-documents (their lines are data, not commands).
if (ch === "$" && next === "'") unsafe.add("$'…' quoting")
if (ch === "$" && next === "{") unsafe.add("parameter expansion")
if (ch === "<" && next === "<" && line[i + 2] !== "<" && line[i - 1] !== "<") unsafe.add("here-document")
if (ch === "'" || ch === '"') {
quote = ch
cur += ch
continue
}
if (ch === "`" || (ch === "$" && next === "(")) unsafe.add("command substitution")
if ((ch === "<" || ch === ">") && next === "(") unsafe.add("process substitution")
if (ch === "\n" || ch === ";") {
flush()
continue
}
if (ch === "&" && next === "&") {
flush()
i++
continue
}
if (ch === "|") {
flush()
if (next === "|") i++
continue
}
if (ch === "&" && next !== ">" && line[i - 1] !== ">") {
flush() // background
continue
}
if (ch === ">") {
// Capture the whole redirection to judge it: `2>&1` and `>/dev/null` are harmless.
let j = i + 1
if (line[j] === ">") j++
if (line[j] === "&") j++
while (line[j] === " ") j++
while (j < line.length && !/[\s;&|]/.test(line[j]!)) j++
const start = /\d|&/.test(line[i - 1] ?? "") ? i - 1 : i
const redir = line.slice(start, j).replace(/\s+/g, "")
if (!SAFE_REDIRECT.test(redir.replace(">>", ">"))) unsafe.add("redirection to a file")
cur += line.slice(i, j)
i = j - 1
continue
}
cur += ch
}
if (quote) unsafe.add("unclosed quote")
flush()
for (const c of commands) {
const head = words(c)[0] ?? ""
if (["eval", "source", "."].includes(head)) unsafe.add(`\`${head}\``)
if (["sh", "bash", "zsh", "dash", "ksh"].includes(head) && /\s-\w*c\b/.test(c)) unsafe.add("nested shell")
}
return { commands, unsafe: [...unsafe] }
}
/** Shell words with quotes removed. Leading VAR=value assignments are skipped. */
export function words(command: string): string[] {
const out: string[] = []
let cur = ""
let quote: string | null = null
let started = false
for (let i = 0; i < command.length; i++) {
const ch = command[i]!
if (quote) {
if (ch === quote) quote = null
else cur += ch
continue
}
if (ch === "'" || ch === '"') {
quote = ch
started = true
continue
}
if (ch === "\\" && i + 1 < command.length) {
cur += command[++i]
started = true
continue
}
if (/\s/.test(ch)) {
if (started) out.push(cur)
cur = ""
started = false
continue
}
cur += ch
started = true
}
if (started) out.push(cur)
while (out.length > 1 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(out[0]!)) out.shift()
return out
}
+347
View File
@@ -0,0 +1,347 @@
// Permission evaluation: rules (OpenCode's shape — last match wins), modes (LLeMbas CLI's four),
// and the hardline floor (Hermes) underneath everything.
import { lstatSync, readlinkSync, realpathSync } from "node:fs"
import { homedir } from "node:os"
import { basename, dirname, join, relative, resolve } from "node:path"
import type { Action, Mode, PermissionConfig } from "../config/schema.ts"
import { prefix } from "./arity.ts"
import { splitCommand, words } from "./bash.ts"
import { hardlineCommand, plainCommands, protectedPath, type HardlineRule } from "./hardline.ts"
import { match } from "./wildcard.ts"
import DEFAULTS from "../../harness/permission/defaults.json"
import ARITY from "../../harness/permission/arity.json"
export interface Rule {
permission: string
pattern: string
action: Action
/** Added by an "always allow" answer: it never overrides a deny somebody wrote. */
learned?: boolean
/** Where it was written. A project's rule never loosens what the user's own global config says. */
source?: "default" | "global" | "project"
}
/** Every tool belongs to one class; modes are defined over classes. */
export type ToolClass = "read" | "write" | "execute" | "interact"
export interface PermissionRequest {
/** The permission key: read, edit, bash, glob, grep, list, ask_user, web_fetch… */
permission: string
class: ToolClass
/** What the rules are matched against: project-relative paths, or the command line. */
patterns: string[]
/** Absolute paths the call touches (file tools). */
paths?: string[]
/** The raw command line (bash). */
command?: string
/** Asked every time, whatever the mode or the rules say — with this reason, and no "always"
* answer (the settings tool loosening the mode). A deny still denies. */
alwaysAsk?: string
/** The "always allow" patterns to offer, when they are not the patterns themselves. */
always?: string[]
}
export interface Decision {
action: Action
reason: string
/** Suggested "always allow" patterns for this request. */
always: string[]
}
// The rules every session starts from: the harness spec's (harness/permission/defaults.json).
export const DEFAULT_RULES = DEFAULTS.rules as PermissionConfig
export function toRules(config: PermissionConfig, source?: Rule["source"]): Rule[] {
const rules: Rule[] = []
for (const [permission, v] of Object.entries(config)) {
if (typeof v === "string") rules.push({ permission, pattern: "*", action: v, ...(source ? { source } : {}) })
else for (const [pattern, action] of Object.entries(v)) rules.push({ permission, pattern, action, ...(source ? { source } : {}) })
}
return rules
}
function lookupRule(permission: string, pattern: string, rules: Rule[]): Rule | undefined {
const hit = rules.findLast((r) => match(permission, r.permission) && match(pattern, r.pattern))
// A project's rule (or an "always" answer) that is looser than what the user's global config says
// for the same call gives way to it: the user's ask and deny are the floor a cloned repository
// stands on, not something it can write over.
if (hit && (hit.source === "project" || hit.learned)) {
const own = rules.findLast((r) => r.source === "global" && !r.learned && match(permission, r.permission) && match(pattern, r.pattern))
if (own && RANK[own.action] > RANK[hit.action]) return own
}
// "Always allow git push *" must not undo a configured "git push --force *": deny.
if (hit?.learned) {
const written = rules.findLast((r) => !r.learned && match(permission, r.permission) && match(pattern, r.pattern))
if (written?.action === "deny") return written
}
return hit
}
/** Whether an allow would cover something a written rule denies (its pattern, read as text,
* matches the allow): `git push *` covers `git push --force *`. */
export function shadowsDeny(allow: Rule, rules: Rule[]): boolean {
return rules.some((r) => !r.learned && r.action === "deny" && match(allow.permission, r.permission) && match(r.pattern, allow.pattern))
}
function lookup(permission: string, pattern: string, rules: Rule[]): Action {
return lookupRule(permission, pattern, rules)?.action ?? "ask"
}
const RANK: Record<Action, number> = { allow: 0, ask: 1, deny: 2 }
const strictest = (a: Action, b: Action): Action => (RANK[a] >= RANK[b] ? a : b)
export interface Context {
mode: Mode
rules: Rule[]
hardline: HardlineRule[]
/** Absolute project root; paths outside it are "external". */
root: string
/** Absolute plan directory: the one place plan mode may write. */
planDir?: string
/** The project directory (.agent): its config, agents, commands and skills are not edits. */
projectDir?: string
}
const inside = (p: string, dir: string) => p === dir || p.startsWith(dir.endsWith("/") ? dir : dir + "/")
/** A path as the filesystem will see it: symlinks in its longest existing part resolved. So a
* link inside the project that points out of it is outside, and one to ~/.ssh is ~/.ssh. */
export function realPath(p: string, depth = 0): string {
const rest: string[] = []
let head = p
for (;;) {
try {
return join(realpathSync(head), ...rest)
} catch {
// A link whose target does not exist yet is still a link: writing through it creates the
// target, wherever that is. Followed by hand, as the filesystem would.
try {
if (depth < 40 && lstatSync(head).isSymbolicLink()) return realPath(join(resolve(dirname(head), readlinkSync(head)), ...rest), depth + 1)
} catch {}
const up = dirname(head)
if (up === head) return p
rest.unshift(basename(head))
head = up
}
}
}
/** Files edit mode does not change unasked: git's own (config and hooks run programs) and the
* project's config, agents, commands and skills (they change what LLeMbas CLI itself does). */
function guardedEdit(p: string, projectDir?: string): boolean {
if (p.split("/").includes(".git")) return true
if (!projectDir || !inside(p, projectDir)) return false
const rel = relative(projectDir, p)
return rel.endsWith("config.yaml") || /^(agents|commands|skills)(\/|$)/.test(rel)
}
const DEVICES = /^\/dev\/(null|zero|stdin|stdout|stderr|tty|u?random)$/
const PATTERN_FIRST = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"])
const MAX_GLOB = 500
/** What a glob names, as the shell would expand it — dotfiles included, to be safe. Undefined
* when it names too many to judge one by one. */
function expandGlob(abs: string): string[] | undefined {
const parts = abs.split("/")
const at = parts.findIndex((x) => /[*?[]/.test(x))
const base = parts.slice(0, at).join("/") || "/"
const out: string[] = []
try {
for (const f of new Bun.Glob(parts.slice(at).join("/")).scanSync({ cwd: base, dot: true, onlyFiles: false, followSymlinks: false })) {
out.push(join(base, f))
if (out.length > MAX_GLOB) return undefined
}
} catch {}
return out.length ? out : [abs]
}
/** The files a command names, absolute: its arguments that are not options (a search tool's
* pattern excepted), what an input redirection reads, and what a glob expands to. A word that is
* not really a path resolves inside the directory, harmlessly. `unsure`: a glob too wide to judge. */
function commandPaths(commands: string[], cwd: string): { paths: string[]; unsure: boolean } {
const out: string[] = []
let unsure = false
const add = (a: string) => {
const home = a === "~" || a.startsWith("~/") ? join(homedir(), a.slice(1)) : a.replace(/^\$\{?HOME\}?(?=\/|$)/, homedir())
const abs = resolve(cwd, home)
if (!/[*?[]/.test(abs)) return void out.push(abs)
const all = expandGlob(abs)
if (all) out.push(...all)
else unsure = true
}
for (const c of commands) {
const w = words(c)
// rg --files lists files: there is no pattern to skip, the first word is a path.
let skipPattern = PATTERN_FIRST.has(basename(w[0] ?? "")) && !w.some((x) => x === "-e" || x.startsWith("--regexp") || x === "-f" || x === "--files")
let afterRedirect = false
let readNext = false
for (const a of w.slice(1)) {
// An input redirection reads a file just as an argument does: `cat < f`, `cat 0<f`.
const input = /^\d*<(?![<>(])(.*)$/.exec(a)
if (input) {
if (input[1]) add(input[1])
else readNext = true
continue
}
if (readNext) {
readNext = false
add(a)
continue
}
if (/^\d*>/.test(a) || a === "&>" || a === "&>>") {
afterRedirect = true
continue
}
if (afterRedirect) {
afterRedirect = false
continue
}
if (!a || a.startsWith("-") || DEVICES.test(a)) continue
if (skipPattern) {
skipPattern = false
continue
}
add(a)
}
}
return { paths: out, unsure }
}
export function evaluate(req: PermissionRequest, ctx: Context): Decision {
const d = evaluateRules(req, ctx)
if (req.alwaysAsk && d.action !== "deny") return { action: "ask", reason: req.alwaysAsk, always: [] }
return d
}
function evaluateRules(req: PermissionRequest, ctx: Context): Decision {
// 1. The floor.
if (req.command) {
const hit = hardlineCommand(req.command, ctx.hardline)
if (hit) return { action: "deny", reason: `refused: ${hit.description} (hardline rule ${hit.id})`, always: [] }
}
// Where each path really is: a symlink is judged by what it points to.
const root = realPath(ctx.root)
const real = (req.paths ?? []).map(realPath)
if (req.class === "write") {
for (const [i, p] of real.entries()) {
const guarded = protectedPath(req.paths![i]!) ?? protectedPath(p)
if (guarded) return { action: "deny", reason: `refused: ${guarded} is never written by a tool`, always: [] }
}
}
// 2. Rules. Every pattern is looked up; the strictest answer wins.
let patterns = req.patterns
let unsafe: string[] = []
if (req.command !== undefined) {
const split = splitCommand(req.command)
patterns = split.commands.length ? split.commands : [req.command]
unsafe = split.unsafe
}
let ruled: Action = "allow"
for (const p of patterns.length ? patterns : ["*"]) ruled = strictest(ruled, lookup(req.permission, p, ctx.rules))
// An allow cannot be trusted when the line does something the split cannot see.
if (ruled === "allow" && unsafe.length) ruled = "ask"
// A written deny is not walked past by another spelling: `sudo -u x git push`, `env A=1 git
// push`, `timeout 5 git push` or `sh -c "git push"` is also judged as plain `git push`. Only a
// deny is taken from the plain spelling; an allow there would loosen what the line asks.
if (req.command !== undefined && ruled !== "deny")
for (const p of plainCommands(req.command)) if (lookup(req.permission, p, ctx.rules) === "deny") ruled = "deny"
// A command is also judged by the files it names: `cat .env` reads .env, `cat ~/.ssh/id` is
// outside the project, whatever the allow rule for `cat *` says.
let named: string[] = []
if (req.command !== undefined) {
const c = commandPaths(patterns, real[0] ?? root)
named = c.paths.map(realPath)
if (c.unsure && ruled === "allow") ruled = "ask"
}
// Reading a file by another tool (grep, glob, list) or a command follows the read rules too.
if (req.permission !== "read" && (req.class === "read" || req.command !== undefined))
for (const p of [...(req.command === undefined ? real : []), ...named])
if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
// And read by where it really is: a link named notes.txt that points at .env reads .env.
if (req.permission === "read")
for (const p of real) if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
const external = [...real, ...named].filter((p) => !inside(p, root))
let externalAction: Action = "allow"
for (const p of external) externalAction = strictest(externalAction, lookup("external_directory", p, ctx.rules))
const always = alwaysPatterns(req, patterns)
if (ruled === "deny") return { action: "deny", reason: "denied by permission rules", always }
if (externalAction === "deny") return { action: "deny", reason: `outside the project: ${external.join(", ")}`, always }
// 3. The mode.
switch (ctx.mode) {
case "auto":
return { action: "allow", reason: "auto mode", always }
case "plan": {
const planDir = ctx.planDir === undefined ? undefined : realPath(ctx.planDir)
const planWrite =
req.class === "write" && planDir !== undefined && inside(planDir, root) && real.length > 0 && real.every((p) => inside(p, planDir))
if (planWrite) return { action: "allow", reason: "plan file", always }
if (req.class === "read" || req.class === "interact") return withExternal(ruled, externalAction, external, always)
if (ruled === "allow") return withExternal("allow", externalAction, external, always)
return {
action: "deny",
reason: "plan mode: only reading and already-approved actions; write the plan under .agent/plans and call plan_submit",
always,
}
}
case "edit": {
// Files, named: a tool that touches no path (skill_manage, an MCP tool) is not file work,
// whatever its class, and keeps asking.
const projectDir = ctx.projectDir === undefined ? undefined : realPath(ctx.projectDir)
const fileWork =
(req.class === "read" || req.class === "write") &&
req.command === undefined &&
real.length > 0 &&
!(req.class === "write" && real.some((p) => guardedEdit(p, projectDir)))
// Edit mode lifts the catch-all `"*": ask`, not a rule somebody wrote for this tool
// (reading `.env` still asks).
const specificAsk = patterns.some((p) => {
const r = lookupRule(req.permission, p, ctx.rules)
return r?.action === "ask" && r.permission !== "*"
})
if (fileWork && external.length === 0 && !specificAsk) return { action: "allow", reason: "edit mode", always }
return withExternal(ruled, externalAction, external, always)
}
case "manual":
return withExternal(ruled, externalAction, external, always)
}
}
function withExternal(ruled: Action, ext: Action, external: string[], always: string[]): Decision {
const action = strictest(ruled, ext)
const reason = action === "allow" ? "allowed by rules" : ext !== "allow" ? `outside the project: ${external.join(", ")}` : "needs approval"
return { action, reason, always }
}
/** Commands that run another command, or can: approving one with `*` would approve anything. */
const EXACT_ONLY = new Set(ARITY.exact_only)
/** "Always allow" patterns: the arity prefix of each command (`git commit *`), or the exact paths.
* None for a line of several commands where one runs others (`curl x | sh`, `find . | xargs rm`):
* "always" would store `curl *` and `sh`, and from then on any `curl … | sh` ran unasked. */
export function alwaysPatterns(req: PermissionRequest, patterns: string[]): string[] {
if (req.always) return req.always
if (req.command === undefined) return patterns
if (patterns.length > 1 && patterns.some((c) => EXACT_ONLY.has(basename(words(c)[0] ?? "")))) return []
const out = new Set<string>()
for (const c of patterns) {
const w = words(c)
if (EXACT_ONLY.has(basename(w[0] ?? ""))) {
out.add(c)
continue
}
const head = prefix(w)
if (!head.length) continue
out.add(head.length < w.length ? `${head.join(" ")} *` : head.join(" "))
}
return [...out]
}
+174
View File
@@ -0,0 +1,174 @@
// The floor: commands that are refused in every mode, auto included, and that no
// project config can re-enable. Patterns ported from Hermes Agent tools/approval_detection.py
// HARDLINE_PATTERNS (MIT, © 2025 Nous Research), plus LLeMbas CLI's own (force-push to main).
import { homedir } from "node:os"
import { basename, posix, resolve } from "node:path"
import { paths } from "../config/paths.ts"
import { splitCommand, words } from "./bash.ts"
import SPEC from "../../harness/permission/hardline.json"
export interface HardlineRule {
id: string
description: string
re: RegExp
}
const rule = (id: string, description: string, src: string): HardlineRule => ({ id, description, re: new RegExp(src, SPEC.flags) })
// The rules themselves are the harness spec's (harness/permission/hardline.json), which LLeMbas
// checks commands against too.
export const BUILTIN_HARDLINE: HardlineRule[] = SPEC.rules.map((r) => rule(r.id, r.description, r.pattern))
export interface HardlineOptions {
extra?: string[]
disable?: string[]
}
export function hardlineRules(opts: HardlineOptions = {}): HardlineRule[] {
const disabled = new Set(opts.disable ?? [])
const rules = BUILTIN_HARDLINE.filter((r) => !disabled.has(r.id))
for (const [i, src] of (opts.extra ?? []).entries()) rules.push(rule(`extra-${i}`, `configured: ${src}`, src))
return rules
}
/** The rule a command line trips, if any. Checked on the raw line, so splitting cannot hide one,
* and on each command in a plain spelling, so writing it differently cannot hide one either. */
export function hardlineCommand(line: string, rules: HardlineRule[]): HardlineRule | undefined {
for (const l of [line, ...plainCommands(line)]) {
const hit = rules.find((r) => r.re.test(l))
if (hit) return hit
}
return undefined
}
// Commands that run the rest of their words as a command, and their options that take a value;
// shell keywords; shells whose -c is a command line; git's options that take a value — the spec's.
const WRAPPERS: Record<string, string[]> = SPEC.plain.wrappers
const KEYWORDS = new Set(SPEC.plain.keywords)
const SHELLS = new Set(SPEC.plain.shells)
const GIT_VALUE = new Set(SPEC.plain.git_value_options)
function plainPath(a: string): string {
const home = homedir()
let p = a === "~" || a.startsWith("~/") ? home + a.slice(1) : a.replace(/^\$\{?HOME\}?(?=\/|$)/, home)
if (!p.startsWith("/")) return a
p = posix.normalize(p)
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1)
if (p === home) return "~"
if (p.startsWith(home + "/")) return "~" + p.slice(home.length)
return p
}
/** Each simple command of a line, rewritten plainly: no VAR= prefixes, wrappers (sudo -u x, env
* -i, timeout 5, xargs…), quotes, escapes or program paths; paths normalised (`/etc/` is /etc,
* the home directory is ~); `sh -c "…"` read as the line it runs. */
export function plainCommands(line: string, depth = 0): string[] {
if (depth > 3) return []
const out: string[] = []
// What a substitution runs is a command too: `echo $(git push)` runs `git push`. Read from
// inside each $( … ) and ` … `, so neither a rule nor the floor is walked past by wrapping a
// command in one.
for (const inner of substitutions(line)) out.push(...plainCommands(inner, depth + 1))
for (const c of splitCommand(line).commands) {
let w = words(c).map((x) => x.replace(/^\(+/, "").replace(/\)+$/, "")).filter((x) => x !== "")
for (;;) {
while (w.length && (KEYWORDS.has(w[0]!) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]!))) w = w.slice(1)
const prog = basename(w[0] ?? "")
const takes = WRAPPERS[prog]
if (!takes) break
let i = 1
while (i < w.length) {
const a = w[i]!
if (a === "--") {
i++
break
}
if (prog === "env" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) {
i++
continue
}
if (prog === "env" && (a === "-S" || a.startsWith("--split-string"))) {
const inner = a.includes("=") ? a.slice(a.indexOf("=") + 1) : w[i + 1]
if (inner) out.push(...plainCommands(inner, depth + 1))
i = w.length
break
}
if (!a.startsWith("-")) break
i += takes.includes(a) ? 2 : 1
}
// timeout's first word after its options is the duration.
if (prog === "timeout" && i < w.length) i++
w = w.slice(i)
}
if (!w.length) continue
const prog = basename(w[0]!)
let args = w.slice(1)
if (SHELLS.has(prog)) {
const flag = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a))
if (flag >= 0 && args[flag + 1] !== undefined) out.push(...plainCommands(args[flag + 1]!, depth + 1))
}
if (prog === "git") {
let i = 0
while (i < args.length && args[i]!.startsWith("-")) i += GIT_VALUE.has(args[i]!) ? 2 : 1
args = args.slice(i)
}
const quote = (x: string) => (/[\s;&|`$()<>'"]/.test(x) ? `'${x.replace(/'/g, "")}'` : x)
out.push([prog, ...args.map(plainPath)].map(quote).join(" "))
}
return out
}
/** The command lines inside a line's $( … ) and ` … ` substitutions (outermost first; nested ones
* are found when each is read in turn). Inside single quotes nothing is a substitution. */
export function substitutions(line: string): string[] {
const out: string[] = []
let quote: "'" | '"' | null = null
for (let i = 0; i < line.length; i++) {
const ch = line[i]!
if (quote === "'") {
if (ch === "'") quote = null
continue
}
if (ch === "\\") {
i++
continue
}
if (ch === "'" && !quote) {
quote = "'"
continue
}
if (ch === '"') {
quote = quote === '"' ? null : '"'
continue
}
if (ch === "$" && line[i + 1] === "(" && line[i + 2] !== "(") {
let depth = 0
let j = i + 1
for (; j < line.length; j++) {
if (line[j] === "(") depth++
else if (line[j] === ")" && --depth === 0) break
}
out.push(line.slice(i + 2, j))
i = j
continue
}
if (ch === "`") {
const end = line.indexOf("`", i + 1)
if (end === -1) break
out.push(line.slice(i + 1, end))
i = end
}
}
return out
}
/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml). */
export function protectedPath(abs: string): string | undefined {
const home = homedir()
const guarded: [string, string][] = SPEC.protected_paths.map((g) => [
g.path.startsWith("<config>/") ? resolve(paths.config, g.path.slice("<config>/".length)) : g.path.startsWith("~/") ? resolve(home, g.path.slice(2)) : g.path,
g.label ?? g.path,
])
for (const [p, label] of guarded) if (abs === p || abs.startsWith(p + "/")) return label
return undefined
}
+19
View File
@@ -0,0 +1,19 @@
// Lifted from OpenCode packages/opencode/src/util/wildcard.ts (MIT, © 2025 opencode).
// `*` matches anything (including `/` and spaces), `?` one character. A pattern ending in
// " *" also matches the bare command, so `ls *` matches both `ls` and `ls -la`.
const cache = new Map<string, RegExp>()
export function match(str: string, pattern: string): boolean {
let re = cache.get(pattern)
if (!re) {
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
re = new RegExp("^" + escaped + "$", "s")
cache.set(pattern, re)
}
return re.test(str.replaceAll("\\", "/"))
}