diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ebd00..41990ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.2.2] - 2026-09-30 + +### New Features + +- Commits the agent makes carry `Co-Authored-By: ANUS `, 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 diff --git a/README.md b/README.md index 81c8355..0f6369d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 `. 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. diff --git a/extension/anus.mjs b/extension/anus.mjs index 2c4d09b..0a862f7 100644 --- a/extension/anus.mjs +++ b/extension/anus.mjs @@ -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)), '..'); @@ -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', { diff --git a/lib/coauthor.mjs b/lib/coauthor.mjs new file mode 100644 index 0000000..e52a53a --- /dev/null +++ b/lib/coauthor.mjs @@ -0,0 +1,34 @@ +/** + * Credit in commits: when the agent runs `git commit`, the commit gets the + * trailer `Co-Authored-By: ANUS ` (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 '; + +// `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); +} diff --git a/lib/main.mjs b/lib/main.mjs index e88a070..d2fd9ad 100644 --- a/lib/main.mjs +++ b/lib/main.mjs @@ -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 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; @@ -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'); @@ -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) { @@ -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]; diff --git a/lib/setup.mjs b/lib/setup.mjs index c71eedd..f580568 100644 --- a/lib/setup.mjs +++ b/lib/setup.mjs @@ -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'); diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..cb64ddd --- /dev/null +++ b/llms.txt @@ -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) diff --git a/package-lock.json b/package-lock.json index 750fa3f..d8cca90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anus-dev/anus", - "version": "0.2.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anus-dev/anus", - "version": "0.2.0", + "version": "0.2.2", "dependencies": { "@earendil-works/pi-coding-agent": "^0.87.1" } diff --git a/package.json b/package.json index b2f7b52..d573524 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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": { diff --git a/test/coauthor.test.mjs b/test/coauthor.test.mjs new file mode 100644 index 0000000..92e5769 --- /dev/null +++ b/test/coauthor.test.mjs @@ -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); +});