Skip to content

refactor(cli): shell consolidation, TypeScript migration & oclif #924

Closed

Description

@cv

Summary

Migrate the NemoClaw CLI from ~4,700 lines of shell + ~4,200 lines of untyped CJS to strict ESM TypeScript on oclif, delivered across 6 sequential PRs. This subsumes #909 (oclif migration) and addresses the same 24 issues and 17 superseded PRs listed there.

No user-facing behavioral changes. The CLI commands, flags, and output stay the same — the internals become typed, tested, and structurally sound.

Motivation

The current CLI has three categories of problem that compound each other:

Shell duplication. Three copies of the install logic (install.sh, scripts/install.sh, inline in onboard.js). A dead setup.sh that leaks NVIDIA_API_KEY on the command line. An uninstall path that exists entirely outside the JS test suite. ~4,700 lines of shell that mostly duplicate logic already in bin/lib/*.js.

No type safety. The 12 modules in bin/lib/ are untyped CJS. The CLI and plugin share domain concepts (sandbox entries, onboard config, credential shapes, endpoint types) but have no shared type definitions — the same shapes are implicit in JS and explicit in TS, with no compile-time guarantee they agree. Silent bugs like registerSandbox({ naem: "foo" }) store name: undefined with no complaint.

No CLI framework. The 515-line switch/case dispatcher in bin/nemoclaw.js has no shell completions, no per-command --help, no flag validation, no --json output, no structured error handling. Fixing these individually is less efficient than fixing the architecture.

Architecture

oclif commands live at the repo root. The OpenClaw plugin (nemoclaw/src/) is a separate concern (Commander-based, different runtime) and is unchanged.

src/
├── commands/              oclif command classes (one file per command)
│   ├── onboard.ts
│   ├── deploy.ts
│   ├── list.ts
│   ├── status.ts
│   ├── start.ts / stop.ts
│   ├── debug.ts / uninstall.ts
│   └── sandbox/
│       ├── connect.ts
│       ├── status.ts
│       ├── logs.ts
│       ├── destroy.ts
│       └── policy/
│           ├── add.ts
│           └── list.ts
├── lib/                   CLI library modules (converted from bin/lib/)
│   ├── runner.ts          Subprocess execution via execa ($$ helper)
│   ├── registry.ts        Multi-sandbox state (~/.nemoclaw/sandboxes.json)
│   ├── credentials.ts     API key and token management
│   ├── platform.ts        OS/arch/Docker socket detection
│   ├── uninstall.ts       Uninstall steps (Docker, npm, state)
│   ├── services.ts        PID management for helper services
│   ├── debug.ts           Diagnostic collection with secret redaction
│   ├── onboard.ts         Onboard wizard logic
│   └── ...
└── hooks/
    └── command-not-found.ts   Backward compat: nemoclaw <sandbox> <action>
shared/
└── types.ts               Domain types shared between CLI and plugin
nemoclaw/
└── src/                   OpenClaw plugin (unchanged)

Two TypeScript projects (tsconfig.cli.json at root + nemoclaw/tsconfig.json) with shared/ bridging them.

Plan

6 sequential PRs. Each builds on the last.

flowchart TD
    PR0["**PR 0** — Foundation ✅ #913\ntsconfig.cli.json · root execa · TS coverage ratchet"]
    PR1["**PR 1** — Security fix + first TS batch\nKill setup.sh · shared/types.ts\nrunner · registry · credentials · platform → src/lib/"]
    PR2["**PR 2** — Port shell scripts to TS\nuninstall.sh · start-services.sh · debug.sh → src/lib/\n+ nim · policies · inference-config · local-inference"]
    PR3["**PR 3** — oclif scaffold + simple commands\nScaffold oclif · list · status · start · stop · debug · uninstall\nMerge installers · port fix-coredns · convert onboard + preflight"]
    PR4["**PR 4** — oclif complex commands\nSandbox topic commands · deploy · onboard wizard\nCentralized error handling · secret redaction"]
    PR5["**PR 5** — E2E migration + cleanup\nAll e2e → vitest TS · shell completions\nDelete old dispatcher · strict TS everywhere"]

    PR0 --> PR1
    PR1 --> PR2
    PR2 --> PR3
    PR3 --> PR4
    PR4 --> PR5

    style PR0 fill:#2d6a2d,color:#fff
Loading

PR 0: Foundation ✅ #913

Done. tsconfig.cli.json (strict, noEmit), execa ^9.6.1 in root devDependencies, scripts/check-coverage-ratchet.ts replacing the bash+python version, tsc-check-cli pre-push hook, CI updated.

PR 1: Kill setup.sh + shared types + first CJS→ESM TS batch

Security fix. scripts/setup.sh passes NVIDIA_API_KEY on the openshell sandbox create command line. onboard.js handles this through the gateway's stored credential. The shell path must die.

  • shared/types.ts — shared domain types with at least one consumer from day one:
    SandboxEntry, SandboxRegistry, EndpointType, NemoClawOnboardConfig, PlatformInfo, CredentialKey
  • Delete scripts/setup.sh (242 lines)
  • Convert 4 core modules (CJS→ESM TS in one step, straight to src/lib/):
    • bin/lib/runner.js → src/lib/runner.ts — rewrite on execa ($$ project helper, delete shellQuote())
    • bin/lib/registry.js → src/lib/registry.ts
    • bin/lib/credentials.js → src/lib/credentials.ts
    • bin/lib/platform.js → src/lib/platform.ts

~240 lines of shell deleted, 4 modules converted. Risk: Low.

PR 2: Port uninstall + start-services + debug to TS

All three are shell scripts that nemoclaw.js shells out to via spawnSync('bash', ...). Each becomes a TS module using execa, called directly.

  • uninstall.sh (555 lines) → src/lib/uninstall.ts
  • scripts/start-services.sh (204 lines) → src/lib/services.ts
  • scripts/debug.sh (328 lines) → src/lib/debug.ts
  • Convert remaining lib modules: nim, policies, inference-config, local-inference

~1,090 lines of shell → ~620 lines of TS + ~400 lines of tests. Risk: Medium.

PR 3: oclif scaffold + simple commands

The biggest structural change. Scaffold oclif at repo root and migrate the simple commands.

Old dispatcher (bin/nemoclaw.js) stays as fallback during this PR. Risk: Medium-high.

PR 4: oclif sandbox commands + onboard wizard + deploy

Risk: High for onboard wizard. Test interactive flow end-to-end.

PR 5: E2E test migration + completions + cleanup

Risk: Medium. Run old and new e2e in parallel for one PR cycle.

End state

pie title Lines of code by language (after)
    "TypeScript (CLI commands)" : 1500
    "TypeScript (CLI lib)" : 3000
    "TypeScript (plugin)" : 4800
    "TypeScript (shared)" : 80
    "TypeScript (e2e tests)" : 700
    "Shell (stays)" : 1300
    "Python (1 script)" : 100
Loading
Before After
~9,600 lines of shell ~1,300 (curl-pipe installers, cloud provisioning, CI utilities)
~4,200 lines of untyped CJS 0
~4,100 lines of shell e2e tests vitest TS with waitFor helper
No CLI framework oclif (auto-help, completions, --json, flag validation)
No shared types shared/types.ts — one definition, two consumers
bash -c + shellQuote() execa array args (no shell, no injection)

Shell that stays (and why)

Script Lines Why
install.sh ~200 Must be curl-pipeable — no Node.js available yet
uninstall.sh ~30 Stub for users who curl-pipe the uninstaller
brev-setup.sh ~167 Linux VM provisioning (apt-get, sudo, systemd)
nemoclaw-start.sh ~220 Sandbox entrypoint with inline Python heredocs
scripts/setup-spark.sh ~142 DGX Spark cgroup/Docker fix, runs under sudo -E
Other CI/dev utilities ~540 check-spdx-headers.sh, backup-workspace.sh, walkthrough.sh, etc.

Subprocess execution: execa replaces child_process

All subprocess calls move from string-concatenated bash -c commands to execa array args. A project-wide $$ helper in src/lib/runner.ts provides defaults.

// Before: string concat + bash -c + shellQuote
run(`docker rm -f ${shellQuote(container)} 2>/dev/null || true`, { ignoreError: true });
const out = runCapture(`docker ps --format '{{.Names}}'`);

// After: execa — no shell, no quoting, typed
await $$({ reject: false })`docker rm -f ${container}`;
const { stdout } = await $$({ stdio: "pipe" })`docker ps --format ${"{{.Names}}"}`;

Conflict management

Must merge before PR 1 (large changes to files we're rewriting):

Should merge if ready (smaller onboard.js changes):

Superseded by oclif migration (close before PR 3):

See #909 for the full list of 24 issues addressed and 17 PRs superseded.

UX compatibility

The nemoclaw <sandbox-name> <action> syntax is preserved via a command_not_found hook — no breaking changes to existing workflows or scripts. The new nemoclaw sandbox <action> <name> syntax is also available.

/cc @cv @HagegeR @prekshivyas

Activity

  1. self-assigned this
    on Mar 25, 2026
  2. BenediktSchackenberg commented on Mar 26, 2026

    @BenediktSchackenberg
    Contributor

    I'd like to pick up PR 1 once #913 lands. I went through the plan and the current codebase — the breakdown makes sense, and the security fix in setup.sh alone justifies getting this started.

    Two things I want to confirm before I start:

    1. shared/types.ts location — the issue says shared/ at repo root with two tsconfig projects referencing it. Should shared/ get its own tsconfig.json that both tsconfig.cli.json and nemoclaw/tsconfig.json extend or reference via project references, or is a simple path mapping in each config enough? Project references would give us incremental builds, but it's also more plumbing for a single file at this stage.

    2. Conflict management — the issue lists fix: harden installer and onboard resiliency #770, fix: add binaries to policy presets + safe config I/O (#676, #692, #606) #782, fix: add local-inference policy preset for Ollama/vLLM host access (#693) #781, and feat: add Podman as supported container runtime for macOS and Linux #819 as "must merge before PR 1." Are those on track to land before chore: add tsconfig.cli.json, root execa, TS coverage ratchet #913, or should PR 1 be written against current main and expect to rebase after they merge?

    Happy to start with a draft branch off cv/pr0-cli-foundation so I don't block on the merge if that helps.

  3. cv commented on Mar 26, 2026

    @cv
    CollaboratorAuthor

    Thanks for the thorough read-through and good questions, @BenediktSchackenberg.

    To answer both at once: I'd like to ask you to hold off on starting PR 1 for now. #913 just landed, but I want to spend more time vetting the overall plan — particularly the sequencing with the open conflict-management PRs (#782, #781, #819) and some of the architectural decisions like the shared/types.ts setup you flagged.

    I'll update this issue when the path forward is clearer and it's ready for someone to pick up. Appreciate the enthusiasm — will ping you when it's time.

  4. cv commented on Apr 1, 2026

    @cv
    CollaboratorAuthor

    Status checkpoint before starting #924.

    I went through the direct links in this issue, their comments/status, and the current open PRs touching the files that PR1/PR2/PR3 will rewrite. Conclusion: #924 should still wait. The remaining pre-work is:

    Already satisfied since this issue was opened

    Must land before PR1 starts

    These are the blockers already named in this issue, and they are still the right ones:

    Should also be resolved before PR1 to avoid immediate churn on the same files

    These are not all called out in the original issue text, but they overlap the exact PR1 rewrite surface enough that starting before they settle would create avoidable conflict/rebase noise.

    runner.js

    scripts/setup.sh / onboarding credential flow

    platform.js / runtime shell path

    registry.js / onboard cleanup

    Scope note from the broader PR audit

    As of today:

    • 27 open PRs touch the immediate PR1 file set (runner.js, registry.js, credentials.js, platform.js, scripts/setup.sh)
    • 39 open PRs touch bin/nemoclaw.js
    • 59 open PRs touch bin/lib/onboard.js

    Most of those are stale/dirty and can be intentionally superseded later, but the queue above is the concrete work that should land (or be explicitly resolved/closed) before we begin the #924 branch in earnest.

  5. BenediktSchackenberg commented on Apr 1, 2026

    @BenediktSchackenberg
    Contributor

    Great checkpoint @cv — really helpful to see the full dependency picture laid out like this.

    Happy to help clear the path for PR1. A few of the blockers/should-haves look stalled or conflicting — I could pick up some of the rebase/cleanup work if that's useful:

    Let me know which of these would be most useful to unblock, or if there's something else on the list where an extra pair of hands would help.

  6. cluster2600 commented on Apr 1, 2026

    @cluster2600
    Contributor

    Cheers for the checkpoint @cv — and thanks for the offer @BenediktSchackenberg.

    Just rebased #782 on main, conflicts resolved — should show as mergeable now. #781 was already clean.

    Happy for @BenediktSchackenberg to pick up #634 if @craigamcw is okay with it, and to consolidate the #672/#794 overlap. That would clear a good chunk of the pre-PR1 blockers.

  7. BenediktSchackenberg commented on Apr 1, 2026

    @BenediktSchackenberg
    Contributor

    Done — opened three PRs to help clear the pre-PR1 path:

    All three are conflict-free and have addressed CodeRabbit review feedback. Happy to consolidate #1246/#1247 into a single PR if that's preferred.

  8. 59 remaining items

  9. cv commented on Jun 2, 2026

    @cv
    CollaboratorAuthor

    Status reconciliation after auditing #924, #909, their linked issues/PRs, and current main-era code.

    Migration ground truth

    The migration was substantially executed, but not exactly as the original 6-PR plan described.

    Implemented/current ground truth:

    • The old hand-written switch dispatcher is gone. bin/nemoclaw.js is now a tiny launcher into dist/nemoclaw.
    • The public CLI surface is backed by oclif command classes under src/commands/**.
    • The historical product grammar (nemoclaw <sandbox-name> <action>) is preserved by src/lib/cli/public-dispatch.ts, which translates into the oclif command tree.
    • Major CLI workflows and libraries moved into TypeScript under src/lib/**, with command adapters kept thin and behavior in src/lib/actions/**, src/lib/domain/**, and src/lib/adapters/**.
    • scripts/setup.sh is gone; setup now routes through nemoclaw onboard.
    • debug.sh, fix-coredns.sh, and uninstall.sh are compatibility wrappers around TypeScript-backed commands/actions.
    • Safe config I/O, registry locking, redaction, platform/runtime detection, policy presets, local inference, diagnostics, and service helpers have TypeScript implementations and tests.
    • CLI/docs parity is enforced by test/e2e/e2e-cloud-experimental/check-docs.sh --only-cli.

    Important ways the evolved implementation differs from the original plan:

    • The CLI is not strict ESM; the build is currently CommonJS (tsconfig.src.json).
    • The subprocess layer is not execa-based; execa is not in package.json. The current safety boundary is argv-array execution in src/lib/runner.ts, with explicit shell escape hatches.
    • There is no root shared/types.ts shared between the root CLI and the plugin. Types are currently distributed, e.g. src/lib/state/registry.ts and nemoclaw/src/onboard/config.ts.
    • Some compatibility CJS shims remain under bin/lib/**, mostly as packaged/back-compat entrypoints.
    • Shell E2E was not fully converted to Vitest TS; shell suites remain part of the active test surface.
    • @oclif/plugin-autocomplete/shell completion did not land.
    • There are still @ts-nocheck islands under src/lib/**, especially in provider/probe/runtime-heavy modules.

    Linked issue/PR status notes

    Prework outcomes from this issue are mostly reflected in current code:

    Still open or not fully satisfied by the migration:

    Follow-up from this audit

    I found one concrete closed-issue drift: #753 claimed nemoclaw <name> policy-list --json, but current code lacked --json for policy-list. I am fixing that separately by adding a structured policy preset report and updating command docs/tests.

    Recommendation: keep #924 open until the maintainers either (a) mark the remaining deltas above as intentional follow-up issues, or (b) update this issue's definition of done to match the evolved architecture and close it as the migration-tracking issue.

  10. added
    area: cliCommand line interface, flags, terminal UX, or output
    and removed on Jun 3, 2026
  11. cv commented on Jun 29, 2026

    @cv
    CollaboratorAuthor

    Closing #924 as completed for the core CLI migration.

    The current implementation now has the intended structural outcome:

    • the old hand-written dispatcher has been replaced by a thin launcher into the TypeScript/oclif command tree;
    • the public nemoclaw <sandbox> <action> grammar remains supported through the public dispatch adapter;
    • the major CLI workflows and libraries live under src/**, with thin command adapters and tested action/domain boundaries;
    • setup, diagnostics, uninstall, policy/config I/O, registry handling, platform/runtime detection, redaction, and related security paths have TypeScript implementations and coverage;
    • per-command help, structured JSON output on supported commands, provider extraction, and the broader CLI test architecture have landed.

    The implementation evolved from the original six-PR design. CommonJS build output, the argv-safe project runner, distributed domain types, compatibility shims, and remaining type-safety islands are accepted implementation details or follow-up debt rather than blockers for this tracker.

    Explicitly outside the reconciled scope of #924:

    • shell completion;
    • global --verbose / --debug flags and LOG_LEVEL observability.

    Those can be reconsidered independently if prioritized.

    Remaining work already has dedicated ownership:

    No further work remains under #924's reconciled scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: cliCommand line interface, flags, terminal UX, or outputrefactorPR restructures code without intended behavior change

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions