Previous: ← Turning Tool Calls Back into Commands

Transforming a CLI into an AI-Native CLI

ESKit / Architecture / AI

August 2026 · ESKit refactoring

Separating Execution from Presentation in ESKit

A small refactoring that created a cleaner boundary for the AI loop

While working on the AI helper for ESKit, I ran into an architectural problem I wasn't originally looking for. The AI helper could already execute ESKit commands through tool calls. The next step was to make it behave more like an agent:

AI → tool call → execute → return result → AI → next tool call

But before building that loop, I realized there was a problem with how the existing CLI handled results.

The CLI Was Doing Two Jobs

ESKit commands are built around Python's argparse. The general flow looked roughly like this:

flowchart LR A["Argparse"] --> B["cmd_()"] B --> C["ESKit core"] C --> D["render()"] D --> E["terminal"]

That works well for a traditional CLI. For example, a command such as show host could call the core function, take the resulting data, apply the configured fields and views, and immediately render a table or JSON output.

The command function knew how the result should be presented. But that becomes awkward when something other than the terminal wants the result. The AI doesn't really want a formatted table. It wants the actual structured result.

A Web UI might want to turn the result into a different interface. An API might want to serialize it differently.

So I started asking:

Why does the command itself need to know how the result will be presented?

Separating Execution from Presentation

I decided to move rendering out of the cmd_* functions. Instead of:

cmd_<command>
│
├── call core
├── build rendering configuration
├── project result
├── render result
└── return exit code

the command becomes much simpler:

flowchart LR A["cmd_()"] --> B["ESKit core"] B --> C["Result"]

The Result object can then move upward to the main application layer.

flowchart LR A["Argparse"] --> B["cmd_()"] B --> C["ESKit core"] C --> D["Result"] D --> E["main()"] E --> F["CLI"] E --> G["AI"] F --> H["Renderer"] G --> J["Agent Loop"]

This means the command is primarily responsible for executing the operation and returning its result. It doesn't need to care whether the caller is a terminal, an AI agent, or something else.

The Result Becomes the Boundary

This also gave me a better reason to make the Result object more useful. Conceptually, it contains things like:

class Result(Generic[T]):
    code: ResultCode
    message: str = ""
    value: T | None = None
    context: Any | None = None
    command_context: Any | None = None

The important part isn't the exact structure. The important part is that the result represents the outcome of the operation before presentation. For the normal CLI, main() can pass that result to the renderer.

The renderer can use the command context to determine things such as:

  • output format
  • selected fields
  • views
  • projection
  • table formatting
  • JSON output

The AI path doesn't need any of that. It can consume the structured result directly.

A Small Refactoring with a Bigger Effect

One of my original cmd_* functions had quite a bit of rendering logic mixed into it.

It would:

  1. load the command context
  2. call the core function
  3. build a field list
  4. normalize the projection
  5. call render()
  6. return an exit code

After the refactoring, the successful path is essentially:

result = get_host(context.host, context.config)
result.command_context = context

if result.code == ResultCode.SUCCESS:
return result

The command still has some error-specific handling for now. I'm not trying to redesign everything at once. But the important change is that successful results are no longer consumed by the renderer inside the command. They are returned. That sounds like a small change, but it creates a much cleaner boundary.

This Wasn't Really an AI Refactoring

Interestingly, I don't think this change is specific to AI. If I had never added the AI helper, separating execution from presentation would still make sense.

The AI experiment simply exposed the problem earlier. A command shouldn't necessarily care whether its result is going to become:

terminal table
JSON API response
AI tool result
Web UI

Those are presentation decisions. The command's job is to perform the operation and return what happened.

And Now the Agent Loop Looks Simpler

This also makes the next architectural step much less intimidating. The agent loop I was thinking about initially looked like it might require special handling throughout ESKit. Now it can be much more straightforward:

flowchart LR A["User"] --> B["AI"] B --> C["ToolCall"] C --> D["Result"] D --> E["ESKit command"] E --> F["ESKit core"] F --> G["Result"] G --> H["AI path"] H --> I["Another ToolCall"] H --> J["Ask User"] H --> K["Final Response"]

The AI layer doesn't need to understand how ESKit renders a table. It just needs the result. That gives me a much cleaner place to implement the agent loop.

One More Unexpected Benefit

The refactoring also made me notice how repetitive the rendering code had become. Many cmd_* functions were doing variations of the same thing:

build fields
    ↓
normalize projection
    ↓
render

Once that responsibility moved out of the commands, a lot of duplicated code disappeared. So even if the AI experiment stopped here, the refactoring would still be useful.

That's probably my favorite kind of architectural change: one that was motivated by a new feature, but leaves the existing system cleaner even if the new feature doesn't work out.

What's Next?

With this boundary in place, I feel much more comfortable moving forward with the AI architecture.

Before completing the agent loop, though, there is another obvious problem:

The command description is too repetitive.

The current command JSON contains a lot of information that is repeated across commands:

--verbose
--debug
--json
--view
...

There are also fields such as null values and other argparse details that aren't necessarily useful to the model.

So the next step is probably some optimization of the command description itself. The goal isn't just to make the JSON smaller.

It's to see how much of the CLI definition can be expressed more intelligently before sending it to the model. Then I can finally get back to the interesting part:

Can this CLI actually become an agent?

If you're interested in the experiment, the ESKit eskit-ai branch is here:

ESKit AI implementation →

This is part of an ongoing series of experiments exploring AI-assisted CLI interfaces and agent architectures in ESKit.

Next: From 25K to 7K Tokens: Simplifying the CLI Definition Without Losing Context →