· 5 min read

ML from the Command Line without Writing Code

Run sentiment analysis, generate embeddings, detect toxicity, and search documents from your terminal. One binary, reads stdin, writes JSON, pipes into any script or CI pipeline.

Classify text, generate embeddings, detect toxicity, and search documents from your terminal. One binary, no code to write.

$ kjarni classify "I love this product!" --model roberta-sentiment
  Input "I love this product!"
         positive  ████████████████████   98.5%
           neutral  ░░░░░░░░░░░░░░░░░░░░    1.1%
          negative  ░░░░░░░░░░░░░░░░░░░░    0.5%
$ kjarni similarity doctor physician
  █████████████████░░░   86.0%  highly similar
  "doctor"
  "physician"

Kjarni is a single binary. Install it, run it. Models download on first use and cache locally.

Commands print a line or two of progress to stderr while a model loads. Those are left out of the examples below for readability; pass -q to silence them.

Install

curl -fsSL https://kjarni.ai/install.sh | sh

No runtime, no dependencies. The binary links against libc and nothing else:

$ ldd $(which kjarni)
    linux-vdso.so.1
    libgcc_s.so.1
    libm.so.6
    libc.so.6
    /lib64/ld-linux-x86-64.so.2

Sentiment Analysis

The default model (distilbert-sentiment) does binary positive/negative classification:

$ kjarni classify "Best purchase I've made this year"
         POSITIVE  ████████████████████  100.0%
          NEGATIVE  ░░░░░░░░░░░░░░░░░░░░    0.0%

For three-class sentiment (positive/negative/neutral), use roberta-sentiment:

$ kjarni classify "It's okay I guess" --model roberta-sentiment
         positive  ██████████░░░░░░░░░░   51.6%
           neutral  █████████░░░░░░░░░░░   44.6%
          negative  █░░░░░░░░░░░░░░░░░░░    3.8%

The model picks up on hedging: "okay I guess" is barely positive at 51.6%, with almost as much weight on neutral. A single label would throw that away; the full distribution is what tells you the model is unsure.

Toxicity Detection

Switch to toxic-bert for content moderation:

$ kjarni classify "i hate mondays" --model toxic-bert
            toxic  ██████████████░░░░░░   69.8%
           obscene  ░░░░░░░░░░░░░░░░░░░░    1.1%
            insult  ░░░░░░░░░░░░░░░░░░░░    0.9%
            threat  ░░░░░░░░░░░░░░░░░░░░    0.5%
     identity_hate  ░░░░░░░░░░░░░░░░░░░░    0.4%

Multi-label, so each category is scored independently. A comment can be both toxic and an insult. Set a threshold (say 80%) and flag content above it.

JSON Output for Scripting

Add --format json to get structured output:

$ kjarni classify "Great service" --format json
{
  "label": "POSITIVE",
  "label_index": 1,
  "predictions": [
    {
      "label": "POSITIVE",
      "score": 0.9998435
    },
    {
      "label": "NEGATIVE",
      "score": 0.00015648185
    }
  ],
  "score": 0.9998435,
  "text": "Great service"
}

Pipe into jq for extraction:

$ kjarni classify "Great service" --format json | jq '.label'
"POSITIVE"

$ kjarni classify "Great service" --format json | jq '.predictions'
[
  { "label": "POSITIVE", "score": 0.9998435 },
  { "label": "NEGATIVE", "score": 0.00015648185 }
]

Batch Processing

Classify a file of reviews, one per line:

$ cat reviews.txt | while read -r line; do
    echo "$line$(kjarni classify "$line" --format json | jq -r '.label')"
  done
Fast shipping, great product → POSITIVE
Arrived damaged, no response from support → NEGATIVE
Best purchase I've made this year → POSITIVE

Embeddings

Generate a 384-dimensional vector from any text:

$ kjarni embed "hello world"
-0.1974462 0.17766516 0.038570307 0.14952245 -0.22542025 -0.9180284 ...

Space-separated floats, one vector, ready to store or compare. Add --normalize to get unit-length vectors, which is what you want if you are going to compare them with a dot product, and what the C# and Go bindings do by default.

Semantic Similarity

Compare two texts by meaning:

$ kjarni similarity doctor physician
  █████████████████░░░   86.0%  highly similar
  "doctor"
  "physician"
$ kjarni similarity doctor banana
  ███████░░░░░░░░░░░░░   33.8%  somewhat related
  "doctor"
  "banana"

The model knows "doctor" and "physician" mean the same thing despite sharing no letters.

Add -q and you get the bare score instead of the chart, which is the form you want in a script:

$ kjarni similarity doctor physician -q
0.859813

Create an index from a folder of text files:

$ kjarni index create ./my-index.idx ./docs/
  Indexed 15 documents
 Index created: ./my-index.idx
  Documents: 15
  Dimension: 384
  Size: 39.52 KB

Search by meaning:

$ kjarni search ./my-index.idx "war"
  Results for "war"
    1. ./docs/romanempire.txt
       ████████████████████  100.0%
       "The Roman Empire collapsed in 476 AD after centuries of political insta…"
    2. ./docs/industrialrevolution.txt
       █████████████████░░░   87.5%
       "The Industrial Revolution began in Britain with mechanized textile prod…"
    3. ./docs/blackholes.txt
       ███████████████░░░░░   75.3%
       "Black holes form when massive stars exhaust their nuclear fuel and unde…"

The index combines BM25 keyword matching with semantic vector search. Add a reranker for more precise results:

$ kjarni search ./my-index.idx "artificial intelligence" --rerank-model minilm-l6-v2-cross-encoder
Reranking top 15 results with 'minilm-l6-v2-cross-encoder'...
  Results for "artificial intelligence"
    1. ./docs/neuralnetworks.txt
       ████████████████████  100.0%
       "Neural networks consist of interconnected layers of artificial neurons …"
    2. ./docs/renaissance.txt
       █████████░░░░░░░░░░░   43.2%
       "During the Renaissance, Florence became a center of artistic innovation…"

The reranker reads the query and each document together (cross-encoder), producing a more precise relevance ranking than embeddings alone.

Text Generation

Complete text with a base language model, which continues your text rather than answering it:

$ kjarni generate "The future of AI is" --model qwen2.5-1.5b --max-tokens 20
 exciting but also very uncertain. The technology has the potential to
 revolutionize many aspects of our lives,

For instruction-following and Q&A, use chat instead, or one of the -instruct models.

Interactive Chat

Chat with instruct-tuned LLMs locally:

$ kjarni chat --model qwen2.5-0.5b-instruct
Kjarni Chat: qwen2.5-0.5b-instruct
Device: Cpu
Type '/help' for commands, '/quit' to exit.
> hello
Hello! How can I assist you today?

A local chatbot running from a single binary, offline once the model has downloaded. Models range from 490MB (qwen2.5-0.5b) to 8B parameters, so you can pick the size that fits your hardware.

Transcription

Transcribe audio files to text using Whisper:

$ kjarni transcribe recording.wav

Supports wav, mp3, flac, and ogg formats. Auto-detects language, or specify with --language en. Add --timestamps for timed output, or --translate to translate to English.

Model Management

List all available models with download status:

$ kjarni model list
Cache: /home/olafurj/.cache/kjarni
Models: 23/28 downloaded

LLM (DECODER)
------------------------------------------------------------------------------------------
   st   qwen2.5-0.5b-instruct            490M [GGUF] Tiny logic engine. Perfect ...
   st   llama3.2-1b-instruct             1.2B [GGUF] Official Meta edge model. V...
   st   llama3.2-3b-instruct             3.2B [GGUF] The 3B standard. Excellent ...
   st   phi3.5-mini                      3.8B [GGUF] Microsoft's 3.8B reasoning ...
         mistral-7b                       7.2B [GGUF] Mistral v0.3. Extremely rel...
         llama3.1-8b-instruct             8.0B [GGUF] The open source standard. R...

SEQ2SEQ
------------------------------------------------------------------------------------------
  ✓ st   flan-t5-base                     250M        General purpose instruction...
  ✓ st   whisper-small                    244M        OpenAI Whisper small for sp...

EMBEDDING
------------------------------------------------------------------------------------------
  ✓ st   minilm-l6-v2                      22M        Fastest sentence embedding ...
  ✓ st   mpnet-base-v2                    110M        High-quality sentence embed...

CLASSIFIER
------------------------------------------------------------------------------------------
  ✓ st   distilbert-sentiment              66M        Fast binary sentiment      ...
  ✓ st   roberta-sentiment                125M        3-class sentiment          ...
  ✓ st   toxic-bert                       110M        Toxic comment classifier   ...

A tick means the weights are cached locally. [GGUF] marks models available in quantised form, which is the format you want for decoders.

Download, inspect, or remove models:

$ kjarni model download llama3.2-1b-instruct
$ kjarni model info minilm-l6-v2
$ kjarni model remove qwen2.5-1.5b

Filter by task or architecture:

$ kjarni model list --task chat
$ kjarni model list --task embedding
$ kjarni model list --downloaded

Inspecting a Model

inspect prints what a model file says about itself: hyperparameters, tensor names, shapes and dtypes. It reads GGUF and safetensors, and takes a path or the name of anything already in your cache.

$ kjarni inspect llama3.2-3b-instruct-q4_k_m
File          ~/.cache/kjarni/llama-3.2-3b-instruct-q4_k_m/Llama-3.2-3B-Instruct-Q4_K_M.gguf
Format        GGUF, 1.88 GiB
Architecture  llama

Metadata
  llama.attention.head_count                   24
  llama.attention.head_count_kv                8
  llama.attention.layer_norm_rms_epsilon       0.00001
  llama.block_count                            28
  llama.context_length                         131072
  llama.embedding_length                       3072
  llama.feed_forward_length                    8192
  llama.rope.freq_base                         500000.0
  llama.vocab_size                             128256
  tokenizer.ggml.bos_token_id                  128000
  tokenizer.ggml.eos_token_id                  128009

Tensors (255 total)

  per layer
    blk.{i}.attn_k.weight       [1024, 3072]    Q4_K   x28
    blk.{i}.attn_q.weight       [3072, 3072]    Q4_K   x28
    blk.{i}.attn_v.weight       [1024, 3072]    Q4_K/Q6_K x28  (varies)
    blk.{i}.ffn_down.weight     [3072, 8192]    Q4_K/Q6_K x28  (varies)
    blk.{i}.ffn_gate.weight     [8192, 3072]    Q4_K   x28

  single
    output_norm.weight          [3072]          F32
    token_embd.weight           [128256, 3072]  Q6_K

The per-layer block is folded to {i} with a count, because a 3B model has a couple of hundred tensors and almost all of them are the same dozen names repeated once per layer. Printing them in full buries the thing you are looking for.

(varies) marks a tensor whose dtype changes between layers. That is not a bug in the file: a _M quantisation deliberately keeps some layers at higher precision, and if you are writing code against these weights it is exactly the sort of thing that will catch you out.

All Commands

$ kjarni
Kjarni: The SQLite of AI

Usage: kjarni [OPTIONS] <COMMAND>

Commands:
  model       Manage models (list, download, info)
  generate    Generate text from a prompt
  summarize   Summarize text
  translate   Translate text between languages
  inspect     Show a model's metadata, config and tensor layout
  embed       Generate embeddings for text
  transcribe  Transcribe audio to text
  classify    Classify text using a classification model
  rerank      Rerank documents by relevance to a query
  chat        Interactive chat mode
  index       Create or manage search indexes
  search      Search an index
  similarity  Compute similarity between two texts

Commands read from arguments or stdin. The four that produce structured data, classify, embed, search and rerank, take --format json and print something jq can read. The rest write text meant for a person. Most commands also take -q to drop progress output, which is what you want in a pipeline. Standard UNIX behaviour: pipe it, script it, cron it.

Practical Recipes

CI Pipeline: Scan PR Comments for Toxicity

gh pr view $PR_NUMBER --json comments -q '.comments[].body' | \
  while read -r comment; do
    score=$(kjarni classify "$comment" --model toxic-bert --format json | \
      jq '.predictions[] | select(.label == "toxic") | .score')
    if (( $(echo "$score > 0.8" | bc -l) )); then
      echo "⚠️  Toxic comment detected: $comment"
    fi
  done

Batch Classify a CSV Column

cut -d',' -f3 reviews.csv | tail -n +2 | \
  while read -r text; do
    kjarni classify "$text" --format json
  done | jq -s '.' > results.json

Quick Sentiment Check on Logs

grep "customer feedback" app.log | \
  sed 's/.*feedback: //' | \
  while read -r line; do
    echo "$(kjarni classify "$line" --format json | jq -r '.label') | $line"
  done

How It Works

The CLI is a thin wrapper around the same Rust inference engine that powers the C# NuGet package. Same models, same accuracy, same local execution. The binary is self-contained, and the only system dependency is glibc.

For the full technical story, see Why I Built a Native ML Inference Engine in Rust.

Install:  curl -fsSL https://kjarni.ai/install.sh | sh
GitHub:   https://github.com/olafurjohannsson/kjarni

Next Steps

Kjarni runs the same engine on every platform: