Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [0.2.2] - 2026-09-30

### New Features

- Commits the agent makes carry `Co-Authored-By: ANUS <noreply@anus.dev>`, like Aider and OpenCode do. Turn it off with `"anusCoauthor": false` in `~/.anus/agent/settings.json` or `ANUS_COAUTHOR=0`. Needs git 2.32 or newer; older git is left alone.
- ANUS now shows up in OpenRouter's ranking of CLI agents.

### Fixed

- `anus --version` no longer needs a writable home folder, and `anus --help` no longer crashes when it cannot write settings ([#93](https://github.com/anus-dev/anus/issues/93)).

## [0.2.1] - 2026-09-29

### Changed
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
[![license](https://img.shields.io/badge/license-MIT-ffb300?labelColor=222222)](LICENSE)
[![Telegram](https://img.shields.io/badge/Telegram-talk%20to%20ANUS-ff4fd8?logo=telegram&logoColor=white&labelColor=222222)](https://t.me/anusonmars_bot)
[![X: @anusonmars](https://img.shields.io/badge/X-@anusonmars-ff2fb1?logo=x&logoColor=white&labelColor=000000)](https://x.com/anusonmars)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/anus-dev/anus)

**A free coding agent in your terminal.** It reads your code, edits files and runs commands, like the paid ones. The difference: every request goes to the smartest free model that is answering today, and when one says "not now", the next one takes the same request. You pay nothing.

Expand Down Expand Up @@ -64,6 +65,10 @@ Inside: `/help` for the basics, `/free` shows the models, `/model` picks one by

Everything is in `~/.anus/agent`: keys (`auth.json`, readable only by you), settings, sessions, and the cached model list. Nothing is sent anywhere except to the model services you gave keys for. `ANUS_DEBUG=1` writes every routing decision to `~/.anus/agent/anus-debug.log`.

## Credit in commits

When ANUS makes a commit, it adds one line at the end: `Co-Authored-By: ANUS <noreply@anus.dev>`. Aider and OpenCode do the same. Don't want it? Put `"anusCoauthor": false` in `~/.anus/agent/settings.json`, or run with `ANUS_COAUTHOR=0`.

## Found a bug

Open an [issue](https://github.com/anus-dev/anus/issues), or tell [@anusonmars_bot](https://t.me/anusonmars_bot) in Telegram: the error text and what you ran. ANUS reads both.
Expand Down
14 changes: 13 additions & 1 deletion extension/anus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
workingLine,
} from '../lib/look.mjs';
import { PROVIDERS, PROVIDER_IDS } from '../lib/providers.mjs';
import { coauthorEnabled, withTrailer } from '../lib/coauthor.mjs';
import { estimateTokens, keyOf, midStreamError, plan, restFor } from '../lib/route.mjs';

const ROOT = process.env.ANUS_ROOT ?? join(dirname(fileURLToPath(import.meta.url)), '..');
Expand Down Expand Up @@ -263,8 +264,19 @@ export default function anus(pi) {

// OpenRouter credits requests to the app named in these headers (its public
// app rankings), and some free models only serve named coding agents.
// The category puts ANUS into OpenRouter's cli-agent ranking.
pi.registerProvider('openrouter', {
headers: { 'HTTP-Referer': 'https://anus.dev', 'X-Title': 'ANUS' },
headers: { 'HTTP-Referer': 'https://anus.dev', 'X-Title': 'ANUS', 'X-OpenRouter-Title': 'ANUS', 'X-OpenRouter-Categories': 'cli-agent' },
});

// Credit in commits (lib/coauthor.mjs): the trailer goes into every
// `git commit` the agent runs, unless the person turned it off.
const credit = coauthorEnabled();
pi.on('tool_call', async (event) => {
if (credit && event.toolName === 'bash' && typeof event.input?.command === 'string') {
event.input.command = withTrailer(event.input.command);
}
return undefined;
});

pi.registerProvider('anus', {
Expand Down
34 changes: 34 additions & 0 deletions lib/coauthor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Credit in commits: when the agent runs `git commit`, the commit gets the
* trailer `Co-Authored-By: ANUS <noreply@anus.dev>` (as Aider and OpenCode
* do). On by default; `"anusCoauthor": false` in
* ~/.anus/agent/settings.json or ANUS_COAUTHOR=0 turns it off.
* `git commit --trailer` needs git 2.32 or newer; older git is left alone.
*/
import { spawnSync } from 'node:child_process';

export const TRAILER = 'Co-Authored-By: ANUS <noreply@anus.dev>';

// `git commit`, `git -C dir commit`, `git -c k=v commit`; not `git commit-tree`.
const WORD = `(?:[^\\s'"]|'[^']*'|"[^"]*")+`; // a shell word, quotes allowed inside
const COMMIT = new RegExp(`\\bgit((?:\\s+-[Cc]\\s+${WORD})*)\\s+commit\\b(?!-)`, 'g');

/** The shell command with the trailer added to every `git commit` in it. */
export function withTrailer(command) {
if (typeof command !== 'string' || command.includes(TRAILER)) return command;
return command.replace(COMMIT, (m) => `${m} --trailer '${TRAILER}'`);
}

/** git 2.32+ knows `commit --trailer`. */
export function gitHasTrailer(versionText) {
const m = /git version (\d+)\.(\d+)/.exec(versionText ?? '');
if (!m) return false;
const [major, minor] = [Number(m[1]), Number(m[2])];
return major > 2 || (major === 2 && minor >= 32);
}

export function coauthorEnabled(env = process.env) {
if (env.ANUS_COAUTHOR === '0') return false;
const r = spawnSync('git', ['--version'], { encoding: 'utf8' });
return r.status === 0 && gitHasTrailer(r.stdout);
}
35 changes: 30 additions & 5 deletions lib/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
* anus start in the current folder (first run asks for free keys)
* anus setup add or change free keys
* anus update update ANUS itself
* anus --version the version, without touching the disk
* anus <anything else> the engine's own flags and commands (anus --help)
*/
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { findEngine, prepareFront, prepareTheme } from './engine.mjs';
import { PROVIDERS } from './providers.mjs';
import { ensureSettings, keyedProviders, runSetup } from './setup.mjs';
import { ensureSettings, keyedProviders, readSettings, runSetup } from './setup.mjs';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
Expand All @@ -22,6 +23,7 @@ const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).ver
const ENGINE_COMMANDS = new Set(['install', 'remove', 'uninstall', 'list', 'config', 'auth']);
const ONE_SHOT = new Set(['-p', '--print', '--mode', '--export']);
const INFO = new Set(['-h', '--help', '-v', '--version', '--list-models']);
const CLOSED = new Set(['EACCES', 'EPERM', 'EROFS']); // a folder we may not write to

export async function main(argv) {
const home = process.env.ANUS_HOME ?? join(homedir(), '.anus');
Expand All @@ -42,9 +44,20 @@ export async function main(argv) {
return;
}

ensureSettings(agentDir);
// The version needs no disk and no engine (issue #93: a closed home folder
// used to crash `anus --version`).
if (argv[0] === '-v' || argv[0] === '--version') {
console.log(VERSION);
return;
}
const command = ENGINE_COMMANDS.has(argv[0]);
const info = argv.some((a) => INFO.has(a));
try {
ensureSettings(agentDir);
} catch (err) {
if (!info) throw err;
}
if (readSettings(agentDir).anusCoauthor === false) process.env.ANUS_COAUTHOR = '0';
const interactive = process.stdin.isTTY && process.stdout.isTTY && !argv.some((a) => ONE_SHOT.has(a));
if (!command && !info && !keyedProviders(agentDir).length) {
if (!interactive) {
Expand All @@ -59,8 +72,20 @@ export async function main(argv) {
}

const engine = findEngine();
prepareTheme({ agentDir, engine });
process.env.PI_PACKAGE_DIR = prepareFront({ home, engine, version: VERSION, root: ROOT });
// A home folder closed for writing (a sandbox, a read-only mount) must not
// stop ANUS: themes are skipped, the front folder moves to the system temp.
try {
prepareTheme({ agentDir, engine });
} catch (err) {
if (!CLOSED.has(err.code)) throw err;
}
try {
process.env.PI_PACKAGE_DIR = prepareFront({ home, engine, version: VERSION, root: ROOT });
} catch (err) {
if (!CLOSED.has(err.code)) throw err;
const spare = join(tmpdir(), `anus-${process.getuid?.() ?? 'user'}`);
process.env.PI_PACKAGE_DIR = prepareFront({ home: spare, engine, version: VERSION, root: ROOT });
}
process.env.PI_SKIP_VERSION_CHECK = '1'; // the engine would compare its own releases with ANUS's version
const args = command ? argv : ['-e', join(ROOT, 'extension', 'anus.mjs'), ...argv];
process.argv = [process.argv[0], engine.cli, ...args];
Expand Down
5 changes: 5 additions & 0 deletions lib/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export function keyedProviders(agentDir, env = process.env) {
export const ANUS_THEME = 'anus-light/anus';

/** Defaults ANUS needs, written only where the person has not set their own. */
/** settings.json of the agent folder, or {} when it is missing or unreadable. */
export function readSettings(agentDir) {
return readJson(join(agentDir, 'settings.json'));
}

export function ensureSettings(agentDir) {
mkdirSync(agentDir, { recursive: true });
const path = join(agentDir, 'settings.json');
Expand Down
14 changes: 14 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# ANUS

> A free coding agent in your terminal. It reads your code, edits files and runs commands. Every request goes to the smartest free model that is answering today; when one says "not now", the next one takes the same request.

- Install: `npm install -g @anus-dev/anus`, then run `anus`. Or: `curl -fsSL https://anus.dev/install | sh`. Needs Node.js 22.19+.
- Free keys: ANUS asks for at least one free key (OpenRouter, Google Gemini, Groq, Cerebras or Mistral). No card. Keys stay on your machine.
- `anus setup` adds or changes keys. `/free` shows which free models are awake.
- Built on the pi coding agent engine (MIT). License: MIT.

## Docs

- [README](https://github.com/anus-dev/anus/blob/main/README.md): install, first run, how "free" works, commands, where files live
- [Changelog](https://github.com/anus-dev/anus/blob/main/CHANGELOG.md)
- [npm package](https://www.npmjs.com/package/@anus-dev/anus)
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anus-dev/anus",
"version": "0.2.1",
"version": "0.2.2",
"description": "ANUS: a free coding agent in your terminal. Every request goes to the smartest free model that is answering today.",
"type": "module",
"bin": {
Expand All @@ -27,12 +27,15 @@
"keywords": [
"anus",
"coding-agent",
"ai-agent",
"free",
"free-models",
"llm",
"openrouter",
"cli",
"terminal",
"pi"
"pi",
"opencode-alternative"
],
"homepage": "https://anus.dev",
"repository": {
Expand Down
32 changes: 32 additions & 0 deletions test/coauthor.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { TRAILER, gitHasTrailer, withTrailer } from '../lib/coauthor.mjs';

const t = `--trailer '${TRAILER}'`;

test('adds the trailer to a plain commit', () => {
assert.equal(withTrailer('git commit -m "fix"'), `git commit ${t} -m "fix"`);
});

test('every commit in a chain, git -C and -c forms', () => {
assert.equal(withTrailer('git add -A && git commit -m a'), `git add -A && git commit ${t} -m a`);
assert.equal(withTrailer('git -C repo commit -am x'), `git -C repo commit ${t} -am x`);
assert.equal(withTrailer("git -c user.name='A B' commit -m x"), `git -c user.name='A B' commit ${t} -m x`);
assert.equal(withTrailer('git commit -m a; git commit --amend --no-edit'), `git commit ${t} -m a; git commit ${t} --amend --no-edit`);
});

test('leaves other commands and repeats alone', () => {
assert.equal(withTrailer('git commit-tree abc'), 'git commit-tree abc');
assert.equal(withTrailer('git status'), 'git status');
assert.equal(withTrailer('npm test'), 'npm test');
const once = withTrailer('git commit -m a');
assert.equal(withTrailer(once), once);
});

test('git 2.32 or newer only', () => {
assert.equal(gitHasTrailer('git version 2.39.5 (Apple Git-154)'), true);
assert.equal(gitHasTrailer('git version 2.32.0'), true);
assert.equal(gitHasTrailer('git version 2.25.1'), false);
assert.equal(gitHasTrailer('git version 3.0.0'), true);
assert.equal(gitHasTrailer(''), false);
});
Loading