https://github.com/devagrawal09/harlan
https://github.com/devagrawal09/harlan
Last synced: 6 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/devagrawal09/harlan
- Owner: devagrawal09
- Created: 2026-05-14T22:23:28.000Z (about 2 months ago)
- Default Branch: master
- Last Pushed: 2026-05-15T00:38:42.000Z (about 2 months ago)
- Last Synced: 2026-05-15T01:32:54.809Z (about 2 months ago)
- Language: TypeScript
- Size: 2.66 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# Harlan
Harlan is a small TypeScript CLI for running an agent that can respond to tasks by writing and executing Harlan code.
Harlan is also the custom language behind that tool: a small, immutable, ML-flavored workflow language for composing tool calls. The first MVP is focused on local filesystem, shell, and text-processing workflows that an agent can write, run, and reuse.
## Requirements
- Node.js 23.6 or newer
- An API key for the model provider you use
## Setup
Install dependencies:
```sh
npm install
```
Create a local environment file:
```sh
cp .env.example .env
```
Then fill in the required API key. The default model uses OpenRouter, so `OPENROUTER_API_KEY` is required unless you change `HARLAN_MODEL` to another provider.
## Usage
Run Harlan with a task:
```sh
npm start -- "write a short greeting"
```
Or pipe a task through standard input:
```sh
echo "write a short greeting" | npm start
```
Choose a model explicitly:
```sh
npm start -- --model openrouter/google/gemini-2.0-flash-lite-001 "write a short greeting"
```
## Harlan Language
Harlan programs are expression-oriented. A program can define immutable bindings with `let`, define functions with `fn`, call tools, and return the value of the final expression.
Modules are loaded with the built-in `import` function:
```harlan
let fs = import("fs")
let text = import("text")
fs.read("README.md")
|> text.lines()
|> text.take(3)
```
Workspace-local user libraries live under `harlan/` and are imported with dot specifiers:
```harlan
import("mymodule.hello")
mymodule.hello.greet("Ada")
```
`import("mymodule.hello")` maps to `harlan/mymodule/hello.harlan` under the runtime working directory. User library imports are side-effecting: they create or extend the persistent namespace tree, so they do not need to be assigned to a `let` binding.
The pipeline operator passes the value on the left as the first argument to the function call on the right:
```harlan
"a\nb\nc"
|> text.lines()
|> text.take(2)
```
Bindings are immutable:
```harlan
let path = "README.md"
let body = fs.read(path)
```
Functions can have simple type annotations:
```harlan
fn first_lines(path: String, count: Number) -> List[String] =
fs.read(path)
|> text.lines()
|> text.take(count)
first_lines("README.md", 5)
```
Records and lists are supported:
```harlan
let task = {
path: "README.md",
count: 5
}
let files = ["README.md", "package.json"]
task.path
```
## Script Logic
Use `if` expressions to branch on tool results:
```harlan
let fs = import("fs")
if fs.exists("README.md") then
fs.read("README.md")
else
"README.md is missing"
```
Comparisons and boolean operators work with explicit booleans:
```harlan
let fs = import("fs")
let info = fs.info("README.md")
info.kind == "file" and info.size > 0
```
Destructuring binds structured records and lists returned by helpers:
```harlan
let fs = import("fs")
let format = import("format")
let { matches, truncated } = fs.search("src", "runHarlan")
if truncated then
"too many results"
else
format.table(matches)
```
Syntax summary:
- `if condition then a else b`
- `==`, `!=`, `<`, `<=`, `>`, `>=`
- `and`, `or`, `not`
- `null`
- `let { field } = record`
- `let [first] = list`
## Built-in Modules
`fs` provides local filesystem access inside the runtime working directory:
```harlan
let fs = import("fs")
fs.cwd()
fs.read("README.md")
fs.list(".")
fs.exists("README.md")
fs.glob("src/**/*.ts")
fs.search("src", "execute_harlan")
fs.info("README.md")
```
`text` provides small text/list helpers:
```harlan
let text = import("text")
text.lines("a\nb")
text.join(["a", "b"], ",")
text.take(["a", "b", "c"], 2)
text.contains("abc", "b")
text.trim(" abc ")
text.lower("ABC")
text.includes(["a", "b"], "b")
```
`format` turns Harlan values into readable strings:
```harlan
let format = import("format")
format.json({ path: "README.md", count: 3 })
format.lines(["README.md", "package.json"])
format.table([{ path: "src/cli.ts", line: 74 }])
```
`shell` runs local shell commands when shell execution is enabled by the host:
```harlan
let shell = import("shell")
shell.run("printf hello")
```
| Module | Functions |
| -------- | ---------------------------------------------------------------- |
| `fs` | `cwd`, `read`, `list`, `exists`, `glob`, `search`, `info` |
| `text` | `lines`, `join`, `take`, `contains`, `trim`, `lower`, `includes` |
| `format` | `json`, `lines`, `table` |
| `shell` | `run` |
## User Libraries
A user library file may contain private top-level `let` bindings, imports, top-level `fn` declarations, and an optional final expression. Only top-level functions are exported.
```harlan
// harlan/mymodule/hello.harlan
let text = import("text")
fn greet(name: String) -> String =
text.trim(name)
```
Import it once in a session:
```harlan
import("mymodule.hello")
```
Then call exported functions through the namespace:
```harlan
mymodule.hello.greet(" Ada ")
```
Import results render signatures, not implementation bodies:
```text
Imported mymodule.hello:
- greet(name: String) -> String
```
Function values also render without bodies:
```text
String>
```
Use `revealImpl` only when the function body is needed:
```harlan
revealImpl(mymodule.hello.greet)
```
`revealImpl` accepts a user-library function value and reveals the source for that function declaration in the tool result, similar to import disclosures. It returns `null`, so it cannot be used to capture source as a string. It does not reveal private top-level `let` bindings or unrelated functions, rejects stdlib or normal session-defined functions, and is intended for agent code or `harlan/init.harlan`, not user libraries.
Slash paths are not valid user-library imports. Use `import("mymodule.hello")`, not `import("mymodule/hello")`.
## Session Init
Persistent agent sessions run `harlan/init.harlan` once before the first session Harlan execution, if the file exists. It is useful for preloading library namespaces:
```harlan
import("mymodule.hello")
import("repo.search")
```
Successful init state is saved into the session snapshot before the requested code runs. If init fails, Harlan returns a warning with the init error and continues running the requested code with the pre-init snapshot.
## Agent Workflow Examples
Prefer Harlan's built-in inspection helpers before reaching for shell commands. Use `fs.glob` for file discovery, `fs.search` for code search, and `format.table` for compact structured results. Use `shell.run` only when the built-in modules are not enough.
Search code and return a Markdown table:
```harlan
let fs = import("fs")
let format = import("format")
fs.search("src", "execute_harlan").matches
|> format.table()
```
List source files:
```harlan
let fs = import("fs")
let format = import("format")
fs.glob("src/**/*.ts")
|> format.lines()
```
Summarize the beginning of the README:
```harlan
let fs = import("fs")
let text = import("text")
fs.read("README.md")
|> text.lines()
|> text.take(10)
```
Example scripts live in `examples/`:
- `examples/readme-summary.harlan`
- `examples/search-code.harlan`
- `examples/list-source.harlan`
## Runtime API
The language can be used directly from TypeScript:
```ts
import { renderHarlanResult, runHarlan } from "./src/harlan/index.ts";
const result = await runHarlan(
`
let fs = import("fs")
let text = import("text")
fs.read("README.md")
|> text.lines()
|> text.take(3)
`,
{ cwd: process.cwd(), allowShell: true },
);
console.log(renderHarlanResult(result));
```
## Development
```sh
npm run typecheck
npm run lint
npm test
npm run format:check
```