///|
/// MoonViz CLI:Agent 命令行接口。
///
/// 用法(stdin 逐行命令,输出 JSON):
/// ```bash
/// echo "list-templates" | moon run --target native cli
/// echo "create login" | moon run --target native cli
/// echo "place login button submit primary 24 400" | moon run --target native cli
/// echo "lint login" | moon run --target native cli
/// ```
///
/// Agent(或 shell 脚本)可以管道组合多个命令。
extern "c" fn getchar() -> Int = "getchar"

///|
fn read_line() -> String {
  let bytes : Array[Int] = []
  let mut on = true
  while on {
    let c = getchar()
    if c == 10 {
      on = false
    } else if c == -1 {
      // stdin EOF:直接返回退出哨兵,避免主循环对空行无限自旋。
      if bytes.length() == 0 {
        return ":exit"
      }
      on = false
    } else if c == 13 {
      // 跳过 \r
    } else {
      bytes.push(c)
    }
  }
  utf8_from_bytes(bytes)
}

///|
/// stdin 是 UTF-8 字节流:逐字节收集后解码(getchar 逐字节返回,
/// 直接用 Int::to_char 会按 Latin-1 拼坏非 ASCII 文本)
fn utf8_from_bytes(bytes : Array[Int]) -> String {
  let sb = StringBuilder::new()
  let mut i = 0
  let n = bytes.length()
  while i < n {
    let b0 = bytes[i]
    if b0 < 0x80 {
      sb.write_char(Int::to_char(b0).unwrap_or('?'))
      i = i + 1
    } else if b0 < 0xE0 && i + 1 < n {
      let cp = ((b0 - 0xC0) << 6) + (bytes[i + 1] - 0x80)
      sb.write_char(Int::to_char(cp).unwrap_or('?'))
      i = i + 2
    } else if b0 < 0xF0 && i + 2 < n {
      let cp = ((b0 - 0xE0) << 12) +
        ((bytes[i + 1] - 0x80) << 6) +
        (bytes[i + 2] - 0x80)
      sb.write_char(Int::to_char(cp).unwrap_or('?'))
      i = i + 3
    } else if i + 3 < n {
      let cp = ((b0 - 0xF0) << 18) +
        ((bytes[i + 1] - 0x80) << 12) +
        ((bytes[i + 2] - 0x80) << 6) +
        (bytes[i + 3] - 0x80)
      sb.write_char(Int::to_char(cp).unwrap_or('?'))
      i = i + 4
    } else {
      i = i + 1
    }
  }
  sb.to_string()
}

///|
/// 设计版本历史(会话级):init 时绑定目标画板,undo/redo/checkout 一律
/// 落回该画板——此前用 keys().next() 取第一个画板,多画板项目会静默写错。
// 按板历史注册表(issue #21):apply 不会使注册失效;init 幂等不重基线,
// 「重开 + init-all」宿主模式可跨 apply 累积多级撤销。
let g_histories : @ref.Ref[Map[String, @core.DesignHistory]] = @ref.Ref::new(
  Map::new(),
)

///|
let g_history_board : @ref.Ref[String] = @ref.Ref::new("")

///|
/// history 子命令族(CLI 侧薄分发;状态在 g_history)。
fn cli_history(p : @core.Project, parts : Array[String]) -> String {
  let sub = if parts.length() >= 2 { parts[1] } else { "" }
  // 从属不变式(issue #21 审查补充):注册表 ⊆ 现存画板
  for k in g_histories.val.keys() {
    if !p.artboards.contains(k) {
      let _ = g_histories.val.remove(k)
    }
  }
  match sub {
    "init" =>
      if parts.length() < 3 {
        cli_err("usage: history init ")
      } else {
        match p.artboards.get(parts[2]) {
          None => cli_err("unknown_artboard: " + parts[2])
          Some(ab) => {
            // 幂等(issue #21):已注册板 init 不重基线,保留已累积历史栈
            let kept = g_histories.val.contains(parts[2])
            if !kept {
              g_histories.val.set(
                parts[2],
                @core.DesignHistory::new(ab.doc.clone()),
              )
            }
            g_history_board.val = parts[2]
            "{\"ok\":true,\"op\":\"history-init\",\"artboard\":\"\{parts[2]}\",\"kept\":\{kept}}"
          }
        }
      }
    "commit" => {
      if parts.length() < 3 {
        return cli_err("usage: history commit  [artboard]")
      }
      let msg = parts[2]
      let target = if parts.length() >= 4 {
        parts[3]
      } else {
        g_history_board.val
      }
      match g_histories.val.get(target) {
        None => cli_err("history_not_initialized: 先 history init ")
        Some(h) =>
          match p.artboards.get(target) {
            None => cli_err("unknown_artboard: " + target)
            Some(ab) => {
              let _ = h.commit("cli", msg, [], ab.doc.clone())
              "{\"ok\":true,\"op\":\"history-commit\",\"rev\":\{h.rev()},\"msg\":\"\{@core.json_escape(msg)}\"}"
            }
          }
      }
    }
    "log" =>
      match g_histories.val.get(g_history_board.val) {
        None => cli_err("history_not_initialized")
        Some(h) =>
          "{\"ok\":true,\"op\":\"history-log\",\"rev\":\{h.rev()},\"log\":\"\{@core.json_escape(h.log())}\"}"
      }
    "undo" | "redo" | "checkout" =>
      match g_histories.val.get(g_history_board.val) {
        None => cli_err("history_not_initialized")
        Some(h) => {
          let want_rev = if sub == "checkout" && parts.length() >= 3 {
            parse_rev(parts[2])
          } else {
            None
          }
          let doc = if sub == "undo" {
            h.undo()
          } else if sub == "redo" {
            h.redo()
          } else {
            match want_rev {
              Some(v) => h.checkout(v)
              None => return cli_err("usage: history checkout ")
            }
          }
          match doc {
            Err(e) => cli_err(e)
            Ok(d) => {
              let target = g_history_board.val
              match p.set_artboard_doc(artboard=target, d) {
                Err(e) => cli_err(e)
                Ok(_) => {
                  let shown = match want_rev {
                    Some(v) => v
                    None => h.rev()
                  }
                  "{\"ok\":true,\"op\":\"history-\{sub}\",\"rev\":\{shown},\"artboard\":\"\{target}\"}"
                }
              }
            }
          }
        }
      }
    "diff" =>
      if parts.length() < 4 {
        cli_err("usage: history diff  ")
      } else {
        match g_histories.val.get(g_history_board.val) {
          None => cli_err("history_not_initialized")
          Some(h) =>
            match (parse_rev(parts[2]), parse_rev(parts[3])) {
              (Some(a), Some(b)) =>
                match h.diff(a, b) {
                  Err(e) => cli_err(e)
                  Ok(json) =>
                    "{\"ok\":true,\"op\":\"history-diff\",\"diff\":\{json}}"
                }
              _ => cli_err("bad_revision")
            }
        }
      }
    _ => cli_err("usage: history init|commit|log|undo|redo|checkout|diff ...")
  }
}

///|
fn parse_rev(text : String) -> Int? {
  let t = text.trim().to_string()
  if t.length() == 0 {
    return None
  }
  let mut v = 0
  let mut i = 0
  while i < t.length() {
    let c = t.substring(start=i, end=i + 1)
    let d = "0123456789".find(c)
    match d {
      None => return None
      Some(x) => v = v * 10 + x
    }
    i = i + 1
  }
  Some(v)
}

///|
fn main {
  let p = @core.Project::new()
  let mut running = true
  println("{\"moonviz\":\"cli\",\"version\":\"\{@core.ENGINE_VERSION}\"}")
  while running {
    let line = read_line()
    // read_line 在 stdin EOF 时返回 ":exit" 哨兵;用户命令 "exit"/"quit"
    // 则由 cli_exec 返回同一哨兵。两条退出路径都必须在这里终止主循环。
    if line == ":exit" {
      running = false
    } else {
      let output = cli_exec(p, line)
      if output == ":exit" {
        running = false
      } else {
        println(output)
      }
    }
  }
}

///|
/// 将一行 Base64 传输载荷严格还原为 UTF-8 MBT 源码。
///
/// CLI 是换行分帧协议,完整 `.mbt.md` 必须经 Base64 传输;这只是传输编码,
/// 不是 DDP 格式,也不是另一份事实源。
fn decode_mbt_b64(payload : String) -> Result[String, String] {
  let bytes = @base64.decode(payload) catch {
    _ => return Err("mbt_transport_invalid_base64")
  }
  let text = @utf8.decode(bytes) catch {
    _ => return Err("mbt_transport_invalid_utf8")
  }
  Ok(text)
}

///|
fn encode_mbt_b64(text : String) -> String {
  @base64.encode(@utf8.encode(text))
}

///|
/// 判断操作是否为引擎修复(还债)操作。
fn mbt_op_is_fix(operation : String) -> Bool {
  mbt_has_cmd_prefix(operation, "fix")
}

///|
/// fix 的还债语义需要前缀匹配:"fix"、"fix "。
fn mbt_has_cmd_prefix(operation : String, prefix : String) -> Bool {
  let op = mbt_trim_ws(operation)
  if op == prefix {
    return true
  }
  let head = prefix + " "
  op.length() > head.length() &&
  op.substring(start=0, end=head.length()) == head
}

///|
/// 去除首尾空白(CLI 操作行可能带换行)。
fn mbt_trim_ws(s : String) -> String {
  let ws = [" ", "\t", "\n", "\r"]
  let mut start = 0
  let mut ended = s.length()
  let mut trimming = true
  while trimming && start < ended {
    let c = s.substring(start~, end=start + 1)
    if ws.contains(c) {
      start = start + 1
    } else {
      trimming = false
    }
  }
  let mut trimming2 = true
  while trimming2 && ended > start {
    let c = s.substring(start=ended - 1, end=ended)
    if ws.contains(c) {
      ended = ended - 1
    } else {
      trimming2 = false
    }
  }
  s.substring(start~, end=ended)
}

///|
/// 全项目谓词违规总数(跨画板求和)。
fn project_total_violations(project : @core.Project) -> Int {
  let mut total = 0
  for id in project.artboards.keys() {
    match project.artboards.get(id) {
      Some(artboard) => total = total + @core.validate(artboard.doc).length()
      None => ()
    }
  }
  total
}

///|
/// 执行一条 CLI 命令,返回 JSON 结果
pub fn cli_exec(p : @core.Project, line : String) -> String {
  let parts = split_cmd(line)
  if parts.length() == 0 {
    return ""
  }
  match parts[0] {
    "help" => cli_help()
    "list-templates" => @core.list_templates_json()
    "component-compile-b64" => cmd_component_compile(p, parts)
    "component-describe" => cmd_component_describe(p, parts)
    "component-delete" => cmd_component_delete(p, parts)
    "component-export" => cmd_component_export(p, parts)
    "component-import" => cmd_component_import(p, parts)
    "library-snapshot" => cmd_library_snapshot(p)
    "library-restore-b64" => cmd_library_restore(p, parts)
    "list-components" => {
      // 合并视图:内置 + 用户自定义(同权,source 标注来源)
      let builtin = @core.list_components_json(p.components)
      let user_items : Array[String] = []
      for id in p.user_components.keys() {
        match p.user_components.get(id) {
          Some(def) => {
            let vs : Array[String] = []
            for v in def.variants.keys() {
              vs.push("\"\{@core.json_escape(v)}\"")
            }
            user_items.push(
              "{\"id\":\"\{@core.json_escape(id)}\",\"kind\":\"user-component\",\"category\":\"\{@core.json_escape(def.category)}\",\"variants\":[\{join_arr(vs)}],\"source\":\"user\",\"description\":\"\{@core.json_escape(def.description)}\"}",
            )
          }
          None => ()
        }
      }
      if user_items.length() == 0 {
        builtin
      } else {
        // builtin 是 [ ... ]:剥掉首尾括号拼接
        let inner = builtin.substring(start=1, end=builtin.length() - 1)
        let sep = if inner == "" { "" } else { "," }
        "[\{inner}\{sep}\{join_arr(user_items)}]"
      }
    }
    "list-tools" => @core.list_tools_json()
    "list-ops" => @core.list_ops_json()
    "list-tokens" => p.tokens.to_json()
    "list-themes" => {
      let themes = @core.builtin_themes()
      let items : Array[String] = []
      for t in themes.iter() {
        items.push("\"" + t.name + "\"")
      }
      let j = join_arr(items)
      "[\{j}]"
    }
    "render-mbt-b64" =>
      if parts.length() < 2 {
        cli_err("usage: render-mbt-b64 ")
      } else {
        match decode_mbt_b64(parts[1]) {
          Err(error) => cli_err(error)
          Ok(text) =>
            match @decl.load_mbt_project(text) {
              Err(error) => cli_err(error)
              Ok(model) => model.render_json()
            }
        }
      }
    "validate-mbt-b64" =>
      if parts.length() < 2 {
        cli_err("usage: validate-mbt-b64 ")
      } else {
        match decode_mbt_b64(parts[1]) {
          Err(error) => cli_err(error)
          Ok(text) =>
            match @decl.load_mbt_project(text) {
              Err(error) => cli_err(error)
              Ok(model) => {
                let entry = @core.json_escape(model.entry)
                let block_kinds = model.document.block_kind_counts()
                "{\"ok\":true,\"entry\":\"\{entry}\",\"revision\":\{model.project.revision},\"blockKinds\":\{block_kinds}}"
              }
            }
        }
      }
    "canonical-mbt-b64" =>
      if parts.length() < 2 {
        cli_err("usage: canonical-mbt-b64 ")
      } else {
        match decode_mbt_b64(parts[1]) {
          Err(error) => cli_err(error)
          Ok(text) =>
            match @decl.load_mbt_project(text) {
              Err(error) => cli_err(error)
              Ok(model) => {
                let canonical_b64 = encode_mbt_b64(model.to_mbt())
                "{\"ok\":true,\"mbt_b64\":\"\{canonical_b64}\"}"
              }
            }
        }
      }
    "load-mbt-b64" =>
      if parts.length() < 2 {
        cli_err("usage: load-mbt-b64 ")
      } else {
        match decode_mbt_b64(parts[1]) {
          Err(error) => cli_err(error)
          Ok(text) =>
            match @decl.load_mbt_project(text) {
              Err(error) => cli_err(error)
              Ok(model) => {
                let entry = @core.json_escape(model.entry)
                p.replace_with(model.project)
                "{\"ok\":true,\"entry\":\"\{entry}\",\"revision\":\{p.revision}}"
              }
            }
        }
      }
    "export-mbt-human" =>
      match @decl.project_to_mbt(p) {
        Err(error) => cli_err(error)
        Ok(model) =>
          match model.commit_and_render(@core.HumanGate) {
            Err(error) => cli_err(error)
            Ok(rendered) => rendered
          }
      }
    "export-mbt-agent" =>
      match @decl.project_to_mbt(p) {
        Err(error) => cli_err(error)
        Ok(model) =>
          match model.commit_and_render(@core.AgentGate) {
            Err(error) => cli_err(error)
            Ok(rendered) => rendered
          }
      }
    "apply-agent-mbt-b64" =>
      if parts.length() < 2 {
        cli_err("usage: apply-agent-mbt-b64 ")
      } else {
        match decode_mbt_b64(parts[1]) {
          Err(error) => cli_err(error)
          Ok(text) =>
            match @decl.load_mbt_project(text) {
              Err(error) => cli_err(error)
              Ok(model) =>
                match model.commit_and_render(@core.AgentGate) {
                  Err(error) => cli_err(error)
                  Ok(rendered) => rendered
                }
            }
        }
      }
    "apply-human-mbt-op-b64" | "apply-agent-mbt-op-b64" =>
      if parts.length() < 3 {
        cli_err(
          "usage: apply--mbt-op-b64  ",
        )
      } else {
        let gate = if parts[0] == "apply-human-mbt-op-b64" {
          @core.HumanGate
        } else {
          @core.AgentGate
        }
        match (decode_mbt_b64(parts[1]), decode_mbt_b64(parts[2])) {
          (Ok(text), Ok(operation)) =>
            match @decl.load_mbt_project(text) {
              Err(error) => cli_err(error)
              Ok(model) => {
                // fix 是引擎的还债操作:AgentGate 阻断时,若总违规数相对
                // 操作前严格下降,允许以 HumanGate 提交(剩余记为视觉债),
                // 否则自动修复对带债项目永远不可用。
                let is_fix = mbt_op_is_fix(operation)
                let before = if is_fix {
                  project_total_violations(model.project)
                } else {
                  0
                }
                // 用户组件注册表是会话级状态(component-compile/library-restore
                // 写入 CLI 全局 p);apply-op 从 MBT 重建的 project 需要注入,
                // 否则该路径 place 用户组件恒 unknown_component。
                for uid in p.user_components.keys() {
                  match p.user_components.get(uid) {
                    Some(udef) => model.project.user_components.set(uid, udef)
                    None => ()
                  }
                }
                match @core.apply_operation(model.project, operation) {
                  Err(error) => cli_err(error)
                  Ok(_) =>
                    match model.commit_and_render(gate) {
                      Err(error) => {
                        let repaid = is_fix &&
                          project_total_violations(model.project) < before
                        if repaid {
                          match model.commit_and_render(@core.HumanGate) {
                            Err(_) => cli_err(error)
                            Ok(rendered) => rendered
                          }
                        } else {
                          cli_err(error)
                        }
                      }
                      Ok(rendered) => rendered
                    }
                }
              }
            }
          (Err(error), _) => cli_err(error)
          (_, Err(error)) => cli_err(error)
        }
      }
    "create" =>
      if parts.length() < 2 {
        cli_err("usage: create  [width] [height]")
      } else {
        let w = if parts.length() >= 3 { parse_d(parts[2]) } else { 390.0 }
        let h = if parts.length() >= 4 { parse_d(parts[3]) } else { 844.0 }
        let name = parts[1]
        let id = p.create_artboard(name~, width=w, height=h)
        "{\"ok\":true,\"artboard\":\"\{id}\",\"w\":\{w},\"h\":\{h}}"
      }
    "template" =>
      if parts.length() < 3 {
        cli_err("usage: template   [width] [height]")
      } else {
        let w = if parts.length() >= 4 { parse_d(parts[3]) } else { 0.0 }
        let h = if parts.length() >= 5 { parse_d(parts[4]) } else { 0.0 }
        match
          p.apply_template(
            template_id=parts[1],
            name=parts[2],
            width=w,
            height=h,
          ) {
          Ok(id) => "{\"ok\":true,\"artboard\":\"\{id}\"}"
          Err(e) => cli_err(e)
        }
      }
    "place" =>
      if parts.length() < 4 {
        cli_err(
          "usage: place    [variant|-] [x] [y] [w] [h] [k=v ...]",
        )
      } else {
        // 参数解析走 @core.parse_place_args——与 op 面(apply_operation
        // 的 place 臂)同一实现,「双路线解析必须一致」结构性兑现
        let pa = @core.parse_place_args(parts)
        if pa.error != "" {
          cli_err(pa.error)
        } else {
          match
            p.place_component(
              artboard=parts[1],
              component_id=parts[2],
              instance_id=parts[3],
              variant=pa.variant,
              x=pa.x,
              y=pa.y,
              w=pa.w,
              h=pa.h,
              args=pa.args,
            ) {
            Ok(msg) => msg
            Err(e) => cli_err(e)
          }
        }
      }
    "query" =>
      if parts.length() < 2 {
        cli_err("usage: query  [kind] [component]")
      } else {
        match p.query_nodes(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "lint" =>
      if parts.length() < 2 {
        cli_err("usage: lint ")
      } else {
        match p.lint(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "critique" =>
      if parts.length() < 2 {
        cli_err("usage: critique ")
      } else {
        match p.critique(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "benchmark" => {
      let b = p.benchmark()
      b.to_json()
    }
    "fix" =>
      if parts.length() < 2 {
        cli_err("usage: fix ")
      } else {
        match p.auto_fix(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "generate" =>
      // 生成式 UI 入口(执行策略第一级):一句话意图 → 引擎生成完整画板
      // + 报告信封。意图支持引号包裹(split_cmd 还原);未加引号时把余下
      // token 空格拼接;仅行尾 "as " 指定画板名(真尾部语义)——
      // 居中的 as 是意图内容("login as hero page"),按普通词并入意图,
      // 否则意图被截断且 "as" 之后的 token 被整体劫持为画板名。
      if parts.length() < 2 {
        cli_err("usage: generate <意图一句话> [spec=] [as ]")
      } else {
        let mut intent = parts[1]
        let mut artboard_name = ""
        let mut spec_json = ""
        let mut i = 2
        while i < parts.length() {
          // 真尾部语义:仅 "as" 是倒数第二个 token("as " 恰好占满
          // 行尾两个 token)才消费为画板名分隔符,与 usage 声明一致
          if parts[i] == "as" && i + 2 == parts.length() {
            artboard_name = parts[i + 1]
            i = i + 2
          } else if parts[i].has_prefix("spec=") && parts[i].length() > 5 {
            // issue #24:spec=(行协议内避免引号/空格)
            match decode_mbt_b64(parts[i].substring(start=5)) {
              Ok(text) => spec_json = text
              Err(e) => return cli_err(e)
            }
            i = i + 1
          } else {
            intent = intent + " " + parts[i]
            i = i + 1
          }
        }
        match p.generate(intent_text=intent, name=artboard_name, spec=spec_json) {
          Ok(json) => json
          Err(e) =>
            if e == "design_spec_required" {
              "{\"ok\":false,\"error\":\"design_spec_required\",\"design_spec_draft\":" +
              @core.design_spec_draft_json(intent) +
              "}"
            } else {
              cli_err(e)
            }
        }
      }
    "ops-batch-b64" =>
      // 执行策略第二级:确定的多条精修一次提交(base64(换行分隔 ops))。
      // 顺序执行、遇错即停(index 随错误返回,供断点续批)。
      if parts.length() < 2 {
        cli_err("usage: ops-batch-b64 ")
      } else {
        match decode_mbt_b64(parts[1]) {
          Err(error) => cli_err(error)
          Ok(block) => {
            let ops : Array[String] = []
            for line in block.split("\n").iter() {
              let trimmed = mbt_trim_ws(line.to_string())
              if trimmed.length() > 0 {
                ops.push(trimmed)
              }
            }
            if ops.length() == 0 {
              cli_err("ops_batch_empty")
            } else {
              match @core.apply_operations(p, ops) {
                Ok(applied) =>
                  "{\"ok\":true,\"op\":\"batch_apply\",\"applied\":\{applied}}"
                Err((index, error)) => {
                  // applied 语义是已应用条数;超限拒绝时 index=-1 是哨兵
                  // 而非条数,信封层与 index 解耦(不可能值会误导断点续批
                  // 逻辑)。op 用工具字典规范名 batch_apply(与 MCP 臂/
                  // list_tools 同名,两臂信封同构)。
                  let applied = if index < 0 { 0 } else { index }
                  "{\"ok\":false,\"op\":\"batch_apply\",\"applied\":\{applied},\"index\":\{index},\"error\":\"\{@core.json_escape(error)}\"}"
                }
              }
            }
          }
        }
      }
    "autofill" =>
      if parts.length() < 2 {
        cli_err("usage: autofill ")
      } else {
        match p.autofill_missing(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "theme" =>
      if parts.length() < 2 {
        cli_err("usage: theme ")
      } else {
        match p.apply_theme(parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "infer" =>
      if parts.length() < 2 {
        cli_err("usage: infer ")
      } else {
        match p.infer_page_type(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "missing" =>
      if parts.length() < 2 {
        cli_err("usage: missing ")
      } else {
        match p.infer_missing(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "export-html" => {
      let html = p.export_html()
      "{\"ok\":true,\"html_length\":\{html.length()},\"html\":\"\{@core.json_escape(html)}\"}"
    }
    "token" =>
      if parts.length() < 3 {
        cli_err("usage: token  ")
      } else {
        match p.set_token(parts[1], parts[2]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "export-svg" =>
      if parts.length() < 2 {
        cli_err("usage: export-svg ")
      } else {
        match p.export_svg(artboard=parts[1]) {
          Ok(svg) =>
            "{\"ok\":true,\"svg_length\":\{svg.length()},\"svg\":\"\{@core.json_escape(svg)}\"}"
          Err(e) => cli_err(e)
        }
      }
    "move" =>
      if parts.length() < 5 {
        cli_err("usage: move    ")
      } else {
        match
          p.move_node(
            artboard=parts[1],
            node_id=parts[2],
            x=parse_d(parts[3]),
            y=parse_d(parts[4]),
          ) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "update" =>
      if parts.length() < 4 {
        cli_err("usage: update   k=v [k=v ...]")
      } else {
        let kvs : Array[(String, String)] = []
        let mut i = 3
        while i < parts.length() {
          let kv = parts[i]
          let eq = index_of_str(kv, "=")
          if eq > 0 {
            let k = kv.substring(start=0, end=eq)
            let v = @decl.unescape_value(
              kv.substring(start=eq + 1, end=kv.length()),
            )
            kvs.push((k, v))
          }
          i = i + 1
        }
        if kvs.length() == 0 {
          cli_err("no k=v pairs given")
        } else {
          match p.edit_node(artboard=parts[1], node_id=parts[2], kvs~) {
            Ok(json) => json
            Err(e) => cli_err(e)
          }
        }
      }
    "delete" =>
      if parts.length() < 3 {
        cli_err("usage: delete  ")
      } else {
        match p.delete_node(artboard=parts[1], node_id=parts[2]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "responsive" =>
      if parts.length() < 2 {
        cli_err(
          "usage: responsive  (生成 _tablet/_desktop 变体)",
        )
      } else {
        match p.generate_responsive(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "save" => p.to_project_json()
    "load" => {
      // 从原始行截取载荷:split_cmd 的引号支持会剥掉 JSON 的双引号,
      // 必须在分词前截取 "load " 之后的原文
      let raw = line.substring(start=4, end=line.length()).trim().to_string()
      if raw.length() == 0 {
        cli_err("usage: load  (JSON 需单行)")
      } else {
        match p.from_project_json(raw) {
          Ok(_) => "{\"ok\":true,\"artboards\":\{p.artboards.size()}}"
          Err(e) => cli_err(e)
        }
      }
    }
    "export-decl" =>
      if parts.length() < 2 {
        cli_err("usage: export-decl  (.mbt.md 声明文本)")
      } else {
        match p.artboards.get(parts[1]) {
          None => cli_err("unknown_artboard: \{parts[1]}")
          Some(ab) => {
            let proto = @decl.from_document(ab.doc)
            let t = proto.to_decl_text()
            "{\"ok\":true,\"decl\":\"\{@core.json_escape(t)}\"}"
          }
        }
      }
    "doc-json" =>
      if parts.length() < 2 {
        cli_err("usage: doc-json ")
      } else {
        match p.artboards.get(parts[1]) {
          None => cli_err("unknown_artboard: \{parts[1]}")
          Some(ab) =>
            "{\"ok\":true,\"json\":\"\{@core.json_escape(ab.doc.to_json())}\"}"
        }
      }
    "duplicate" =>
      if parts.length() < 3 {
        cli_err("usage: duplicate  ")
      } else {
        match p.duplicate_artboard(src=parts[1], new_name=parts[2]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "delete-artboard" =>
      if parts.length() < 2 {
        cli_err("usage: delete-artboard ")
      } else {
        match p.delete_artboard(id=parts[1]) {
          Ok(json) => {
            // 从属不变式(issue #21):删板即清历史注册,同 id 重建不复活旧栈
            let _ = g_histories.val.remove(parts[1])
            json
          }
          Err(e) => cli_err(e)
        }
      }
    "flow" =>
      // 建立导航流:flow     (tap node → 导航)
      if parts.length() < 4 {
        cli_err("usage: flow   ")
      } else {
        let trigger = "tap:\{parts[3]}"
        p.add_flow(from=parts[1], to=parts[2], trigger~)
        "{\"ok\":true,\"flow\":\"\{parts[1]} --tap:\{parts[3]}--> \{parts[2]}\"}"
      }
    "flows" => p.flow_json()
    "unflow" =>
      // 撤销单条导航流:unflow   (op_entries
      // 注册表宣告的 CLI op 面,缺臂会让按 list-ops 逐字发令的 Agent
      // 撞 unknown_command——与 flow 臂对称)
      if parts.length() < 4 {
        cli_err("usage: unflow   ")
      } else {
        match
          p.remove_flow(from=parts[1], to=parts[2], trigger="tap:\{parts[3]}") {
          Ok(_) =>
            "{\"ok\":true,\"unflow\":\"\{parts[1]} --tap:\{parts[3]}--> \{parts[2]}\"}"
          Err(e) => cli_err(e)
        }
      }
    "group" =>
      if parts.length() < 5 {
        cli_err("usage: group     [n3 ...]")
      } else {
        let ids : Array[String] = []
        let mut i = 3
        while i < parts.length() {
          ids.push(parts[i])
          i = i + 1
        }
        match
          p.group_nodes(artboard=parts[1], node_ids=ids, group_id=parts[2]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "ungroup" =>
      if parts.length() < 3 {
        cli_err("usage: ungroup  ")
      } else {
        match p.ungroup_node(artboard=parts[1], group_id=parts[2]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "align" =>
      if parts.length() < 5 {
        cli_err("usage: align     [n3 ...]")
      } else {
        let ids : Array[String] = []
        let mut i = 3
        while i < parts.length() {
          ids.push(parts[i])
          i = i + 1
        }
        match p.align_nodes(artboard=parts[1], mode=parts[2], node_ids=ids) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "flip" =>
      if parts.length() < 4 {
        cli_err("usage: flip   h|v|both|none")
      } else {
        match p.flip_node(artboard=parts[1], node_id=parts[2], axis=parts[3]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "copy" =>
      if parts.length() < 4 {
        cli_err("usage: copy    [dx] [dy]")
      } else {
        let dx = if parts.length() >= 5 { parse_d(parts[4]) } else { 24.0 }
        let dy = if parts.length() >= 6 { parse_d(parts[5]) } else { 24.0 }
        match
          p.copy_node(
            artboard=parts[1],
            node_id=parts[2],
            new_id=parts[3],
            dx~,
            dy~,
          ) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "resize-canvas" =>
      // Figma Constraints:画板尺寸变更 + 按约束智能重排
      if parts.length() < 4 {
        cli_err("usage: resize-canvas   ")
      } else {
        match
          p.resize_canvas(
            artboard=parts[1],
            width=parse_d(parts[2]),
            height=parse_d(parts[3]),
          ) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "reorder" =>
      // Z 序:reorder   front|back|up|down
      if parts.length() < 4 {
        cli_err("usage: reorder   front|back|up|down")
      } else {
        match
          p.reorder_node(artboard=parts[1], node_id=parts[2], order=parts[3]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "restyle" =>
      // 主组件传播:restyle   k=v ...
      if parts.length() < 4 {
        cli_err("usage: restyle   k=v [k=v ...]")
      } else {
        let kvs : Array[(String, String)] = []
        let mut i = 3
        while i < parts.length() {
          let kv = parts[i]
          let eq = index_of_str(kv, "=")
          if eq > 0 {
            kvs.push(
              (
                kv.substring(start=0, end=eq),
                kv.substring(start=eq + 1, end=kv.length()),
              ),
            )
          }
          i = i + 1
        }
        if kvs.length() == 0 {
          cli_err("no k=v pairs")
        } else {
          match
            p.restyle_component(artboard=parts[1], component_id=parts[2], kvs~) {
            Ok(json) => json
            Err(e) => cli_err(e)
          }
        }
      }
    "tap" =>
      // 播放模式命中:tap    → 状态变更 JSON
      if parts.length() < 4 {
        cli_err("usage: tap   ")
      } else {
        match @core.MoonVizRT::create(p, initial_artboard=parts[1]) {
          Err(e) => cli_err(e)
          Ok(rt) => {
            let changes = rt.handle_event(
              @core.TapEvent(parse_d(parts[2]), parse_d(parts[3])),
            )
            let items : Array[String] = []
            for c in changes.iter() {
              let d = c.describe()
              let (typ, val) = split_change(d)
              items.push("{\"type\":\"\{typ}\",\"value\":\"\{val}\"}")
            }
            "{\"ok\":true,\"artboard\":\"\{parts[1]}\",\"changes\":[\{join_arr(items)}]}"
          }
        }
      }
    "export-artifact" => p.export_artifact(agent_id="cli")
    "interact" =>
      // interact     :注册节点交互(写入标记)
      if parts.length() < 5 {
        cli_err(
          "usage: interact      (trigger: tap|long_press|swipe_left|swipe_right|swipe_up|swipe_down|scroll_end|key_enter|focus|blur;action: back|haptic|navigate_to:|show_toast:\\\"msg\\\"|set_text::\\\"txt\\\"|set_state::|toggle_state:|play_sound:)",
        )
      } else {
        match
          p.attach_interaction(
            artboard=parts[1],
            node_id=parts[2],
            trigger=parts[3],
            action_spec=parts[4],
          ) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "uninteract" =>
      if parts.length() < 3 {
        cli_err("usage: uninteract  ")
      } else {
        match p.detach_interaction(artboard=parts[1], node_id=parts[2]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "interactions" =>
      if parts.length() < 2 {
        cli_err("usage: interactions ")
      } else {
        match p.interactions_json(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "state" =>
      // state    k=v ... :定义组件多态状态补丁
      if parts.length() < 5 {
        cli_err(
          "usage: state    k=v [k=v ...]  (k 同 update: fill/text_color/radius/w/h/...)",
        )
      } else {
        let mut fill : String? = None
        let mut text_color : String? = None
        let mut stroke : String? = None
        let mut text : String? = None
        let mut nm : String? = None
        let mut radius : Double? = None
        let mut font_size : Double? = None
        let mut gap : Double? = None
        let mut i = 4
        let mut bad = ""
        while i < parts.length() {
          let eq = index_of_str(parts[i], "=")
          if eq <= 0 {
            if bad == "" {
              bad = "state_invalid_arg:" + parts[i]
            }
          } else {
            let k = parts[i].substring(start=0, end=eq)
            let v = @decl.unescape_value(parts[i].substring(start=eq + 1))
            let d = parse_d(v)
            match k {
              "fill" => fill = Some(v)
              "text_color" => text_color = Some(v)
              "stroke" => stroke = Some(v)
              "text" => text = Some(v)
              "name" => nm = Some(v)
              "radius" => radius = Some(d)
              "font_size" => font_size = Some(d)
              "gap" => gap = Some(d)
              _ => ()
            }
          }
          i = i + 1
        }
        if bad != "" {
          cli_err(bad)
        } else {
          let patch : @core.NodePatch = {
            ..@core.NodePatch::empty(),
            fill,
            text_color,
            stroke,
            text,
            name: nm,
            corner_radius: radius,
            font_size,
            layout_gap: gap,
          }
          match
            p.define_component_state(
              artboard=parts[1],
              node_id=parts[2],
              state_name=parts[3],
              patch~,
            ) {
            Ok(json) => json
            Err(e) => cli_err(e)
          }
        }
      }
    "states" =>
      if parts.length() < 2 {
        cli_err("usage: states ")
      } else {
        match p.node_states_json(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "set-state" =>
      // 文法 set-state   [toggle]:最小合法输入是 3 段
      // (此前 <4 双重 off-by-one:最小形式被拒、toggle 永远打不开),
      // 与 core/ops.mbt set-state 臂逐字对齐
      if parts.length() < 3 {
        cli_err("usage: set-state   [toggle]")
      } else {
        let toggle = parts.length() >= 4 && parts[3] == "toggle"
        match p.set_node_state(node_id=parts[1], state_name=parts[2], toggle~) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "constrain" =>
      // constrain   :自然语言布局意图(居中对齐 / 垂直排列 / 等宽 / 间距 N)
      if parts.length() < 3 {
        cli_err("usage: constrain  ")
      } else {
        let raw = line.substring(start=10, end=line.length()).trim().to_string()
        let sp = index_of_str(raw, " ")
        if sp <= 0 {
          cli_err("usage: constrain  ")
        } else {
          let ab = raw.substring(start=0, end=sp)
          let intent = raw.substring(start=sp + 1, end=raw.length())
          match p.apply_constraint(intent, artboard=ab) {
            Ok(json) => json
            Err(e) => cli_err(e)
          }
        }
      }
    "spec" =>
      if parts.length() < 2 {
        cli_err("usage: spec ")
      } else {
        match p.generate_spec(artboard=parts[1]) {
          Ok(spec) => "{\"ok\":true,\"spec_length\":\{spec.length()}}"
          Err(e) => cli_err(e)
        }
      }
    "export-design-system" =>
      if parts.length() < 2 {
        cli_err("usage: export-design-system ")
      } else {
        match p.export_design_system(artboard=parts[1]) {
          Ok(json) => json
          Err(e) => cli_err(e)
        }
      }
    "list" => p.list_artboards_json()
    "exit" | "quit" => ":exit"
    "anim-list" => @core.animation_presets_json()
    "history" => cli_history(p, parts)
    "protest" =>
      match line.find(" ") {
        None =>
          cli_err(
            "usage: protest