Transforming a CLI into an AI-Native CLI
ESKit / AI / Token Optimization
August 2026 · ESKit experiment
From 25K to 7K Tokens: Simplifying the CLI Definition Without Losing Context
After getting the basic AI CLI helper working and turning Claude's tool calls into actual ESKit commands, I started looking at something I had mostly ignored until then: how much information I was actually sending to the model. The first version of my AI interface passed a fairly complete representation of ESKit's CLI command definitions to Claude. This made sense initially because the CLI already had all of the information needed to describe its commands, arguments, options, and subcommands.
But the resulting representation was about 55 KB, and the request consumed roughly 25,000 input tokens. It's much more than I used in the other AI experiment I was doing for ESKit by giving them a separate tools definitions... So I started feeling like it was unnecessarily expensive — basically throwing coins at the model every time I executed something.
However, I didn't want to create a second, AI-specific definition of the CLI, though. One idea behind this experiment was that the CLI's existing argument definitions should remain the source of truth.
So instead of changing the CLI definitions, I thought it would be fairly trivial to reduce the definitions without coming up with anything complex before sending them to the AI.
The source definition stays unchanged
I wasn't trying to make the actual ESKit CLI definition
smaller. The argparse definitions are still the
definitions ESKit uses. They need to contain whatever
information the CLI needs in order to parse and execute
commands.
Instead, I introduced a separate transformation step:
The original definition remains untouched. This also meant I didn't have to maintain two schemas and worry about them getting out of sync. The AI representation is simply a derived view of the CLI.
Removing information the AI doesn't need
The first pass is simple.
Some fields in the generated command definition were useful
internally but didn't provide much value to Claude.
For example, nargs was one of the fields I could
remove.
There were also many None values and empty
command collections that didn't add useful information to
the description.
So I introduced a small CommandPass abstraction
and made the first pass responsible for removing these
fields.
Conceptually:
Before:
{
"name": "index_create",
"nargs": null,
"help": "...",
"commands": {},
...
}
After:
{
"name": "index_create",
"help": "...",
...
}
Nothing about the actual CLI changed. It just removed information from the representation being sent to Claude.
Removing duplicated common arguments
The bigger opportunity came from looking at the CLI's common arguments.
Options such as:
--host
--config
--json
--view
--fields
--verbose
--debug
--dry-run
were appearing repeatedly throughout the command hierarchy.
From the CLI's perspective, that's perfectly reasonable.
Each command needs to know which arguments it accepts.
But from the AI's perspective, repeatedly describing the same
argument definitions is redundant.
So I added another pass:
DeduplicateCommonArgs.
Instead of keeping the same definition attached to every
command, the pass extracts common arguments and places them
in a shared common_arguments section.
For example, conceptually, the original representation might look like this:
Before:
command A
├── --host
│ ├── description
│ └── ...
├── --json
│ ├── description
│ └── ...
└── --verbose
├── description
└── ...
command B
├── --host
│ ├── description
│ └── ...
├── --json
│ ├── description
│ └── ...
└── --verbose
├── description
└── ...
command C
├── --host
│ ├── description
│ └── ...
├── --json
│ ├── description
│ └── ...
└── --verbose
├── description
└── ...
The definitions of those common arguments are repeated for every command.
After the transformation, the common argument definitions are moved into a shared section, while each command keeps a reference to the arguments it supports:
After:
common_arguments
├── host
│ ├── description
│ └── ...
├── json
│ ├── description
│ └── ...
└── verbose
├── description
└── ...
command A
└── common_args: [host, json, verbose, ...]
command B
└── common_args: [host, json, verbose, ...]
command C
└── common_args: [host, json, verbose, ...]
The important difference is that the argument definitions themselves are no longer repeated. The commands still tell the AI which common arguments are available, while the actual descriptions and other metadata appear only once.
From the model's perspective, the necessary context is still there. It just doesn't have to be serialized repeatedly.
The pass architecture
I wanted these transformations to stay independent rather than putting a growing collection of special cases into the code that generates the command definition.
So I made each transformation a CommandPass:
class CommandPass:
def apply(self, command):
raise NotImplementedError
Each pass takes a command definition and returns another command definition.
The passes can then be chained:
result = run_passes(command, [
RemoveUnnecessaryFields(),
DeduplicateCommonArgs(),
])
The important part isn't the abstraction itself. It gives me a place to add future transformations. If I discover another piece of information that doesn't need to be sent to the model, I can add another pass without changing the original CLI definition. Maybe I can add a pass to remove commands that I don't want AI to know depending on host configurations or user configurations.
The pipeline becomes a kind of boundary between the complete representation needed by the CLI and the smaller representation needed by the AI.
25K tokens became about 7K
After applying the transformations, the representation dropped from roughly 55 KB to 17 KB. More importantly, the input context dropped from about 25,000 tokens to roughly 7,000 tokens.
That's around a 72% reduction in input tokens:
- I didn't change the underlying CLI.
- I didn't introduce a new schema.
- I didn't implement a sophisticated compression technique.
I simply removed information that wasn't useful to the model and eliminated repeated definitions. That was a huge gain and useful lesson.
Less context doesn't necessarily mean less capability
One concern I had when removing information was whether Claude would still understand the CLI well enough to use it. After all, I initially sent so much information because I wanted to make sure the model had enough context to construct
But the optimized representation still worked. In fact, this reinforced something I had already started noticing during the earlier experiments: the model doesn't necessarily benefit from seeing everything available to the application. It needs the information relevant to the task.
This is particularly important when the context is describing an existing interface. The complete internal representation may be useful to the program, but that doesn't mean it is the best representation for an LLM.
I asked Claude the same questions I asked in
Part 1
.
And the results are very much similar and satisfying.
Test Result with optimized command definitions.
.
The CLI remains the source of truth
This ended up being the part of the design I liked the most. I didn't want ESKit to become:
Instead, it became:
The AI interface is generated from the same definitions that power the CLI. That means adding a new command or argument to ESKit doesn't require me to manually update another AI-specific schema. The AI layer can keep deriving its understanding of the CLI from the CLI itself.
What I learned
This optimization was a relatively small engineering change, but it changed how I think about giving context to an AI system.
My initial instinct was essentially:
Give the model everything. More information should make it more capable.
The experiment suggested something more nuanced:
Give the model the information it needs, not necessarily all the information the application has.
The difference matters when the interface becomes large.
In my case, a few straightforward transformations reduced the context from roughly 25K tokens to 7K while keeping the same underlying CLI definitions and preserving the information Claude needed to work with ESKit.
The CommandPass architecture also gives me a
useful place to continue experimenting without coupling those
optimizations to the CLI itself.
And that separation may become increasingly important as the AI interface becomes more capable.
What's next?
At this point, the AI helper could understand the CLI and turn Claude's tool calls back into real ESKit commands. But there was still an important limitation.
The interaction was essentially:
For simple requests, that was enough. But what happens when a request requires several operations?
For example:
"Check the health of host X and tell me if anything looks wrong."
Claude might need to check the host, retrieve the latest cache, inspect index health, look at repositories, and then reason about the results. That requires more than a single tool call.
It requires an agent loop.
That became the next step in the ESKit experiment.
This is part of an ongoing series of experiments exploring AI-assisted CLI interfaces and agent architectures in ESKit.