assurgent 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,175 @@
1
- # assurgent
1
+ # assurgent Chat Wrapper
2
+
3
+ A lightweight wrapper that connects chat (e.g. **Telegram**) to Agent (e.g. **Claude Code CLI**), so you can talk to Claude from your phone — with your entire assurgent workspace as context.
4
+
5
+ > Experimental project, early alpha. Expect bugs and breaking changes, use at your own risk.
6
+
7
+ ```
8
+ You (Telegram) --> Wrapper --> Claude Code CLI --> Response --> You (Telegram)
9
+ ```
10
+
11
+ It is not an agent itself. It's a bridge between Telegram and the Claude Code CLI, with automatic session management.
12
+
13
+ ## Quick Start
14
+
15
+ ### Prerequisites
16
+
17
+ - [Bun](https://bun.sh) runtime
18
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
19
+ - A Telegram bot token (from [@BotFather](https://t.me/BotFather))
20
+
21
+ ### Install
22
+
23
+ ```bash
24
+ cd app
25
+ bun install
26
+ ```
27
+
28
+ ### Configure
29
+
30
+ ```bash
31
+ cp config.example.json config.json
32
+ ```
33
+
34
+ Edit `config.json`:
35
+
36
+ ```json
37
+ {
38
+ "chat": {
39
+ "adapter": "telegram",
40
+ "telegram": {
41
+ "botToken": "YOUR_BOT_TOKEN_HERE",
42
+ "allowedUserIds": ["YOUR_TELEGRAM_USER_ID"],
43
+ "placeholder": {
44
+ "enabled": true,
45
+ "text": "thinking..."
46
+ }
47
+ }
48
+ },
49
+ "agent": {
50
+ "adapter": "claude-code",
51
+ "claude-code": {
52
+ "model": "sonnet",
53
+ "maxTurns": 10,
54
+ "flags": ["--dangerously-skip-permissions"],
55
+ "claudePath": "claude"
56
+ }
57
+ },
58
+ "session": {
59
+ "turnLimit": 20
60
+ },
61
+ "workspacePath": "/path/to/assurgent"
62
+ }
63
+ ```
64
+
65
+ To find your Telegram user ID, message [@userinfobot](https://t.me/userinfobot).
66
+
67
+ > **Note:** If `claude` is not in your shell's PATH (common on servers where PATH is set in `.bashrc`), set `claudePath` to the full path, e.g. `/home/user/.local/bin/claude`.
68
+
69
+ ### Run
70
+
71
+ ```bash
72
+ bun run dev
73
+ ```
74
+
75
+ Then message your bot on Telegram.
76
+
77
+ ## Bot Commands
78
+
79
+ | Command | Description |
80
+ | --------------------------------------- | ---------------------------------------------- |
81
+ | `/new` | Archive current session, start fresh |
82
+ | `/extend [N]` | Extend session by N turns (default: turnLimit) |
83
+ | `/model [opus\|sonnet\|haiku\|default]` | Show or change model for current session |
84
+ | `/session list` | List all sessions with turn usage |
85
+ | `/session info` | Show current session details |
86
+ | `/session resume <name>` | Resume a session by name |
87
+ | `/session rename <name>` | Rename current session |
88
+ | `/help` | Show all commands |
89
+
90
+ Any other text message is forwarded to Claude Code in the current session.
91
+
92
+ ## Sessions
93
+
94
+ Sessions always resume the active session until you explicitly start a new one with `/new`.
95
+
96
+ When the turn count reaches the configured `turnLimit` (default: 20), the bot pauses and asks you to decide:
97
+ - `/extend 20` to add more turns and keep the same session
98
+ - `/new` to archive and start fresh
99
+
100
+ Session names are auto-generated from the date and first message (e.g. `2026-03-27-fix-bug`).
101
+
102
+ You can override the model per-session with `/model sonnet` — this persists until you reset with `/model default` or start a new session.
103
+
104
+ Sessions persist across restarts via `state/sessions.json` (relative to `workspacePath`).
105
+
106
+ ## Config Reference
107
+
108
+ | Field | Description |
109
+ | ----------------------------------- | ----------------------------------------------------------- |
110
+ | `chat.adapter` | Chat platform (`"telegram"`) |
111
+ | `chat.telegram.botToken` | Telegram bot token |
112
+ | `chat.telegram.allowedUserIds` | Array of allowed Telegram user IDs |
113
+ | `chat.telegram.placeholder.enabled` | Show a placeholder message while agent thinks |
114
+ | `chat.telegram.placeholder.text` | Placeholder text |
115
+ | `agent.adapter` | Agent CLI (`"claude-code"`) |
116
+ | `agent.claude-code.model` | Default model (e.g. `"opus"`, `"sonnet"`) |
117
+ | `agent.claude-code.maxTurns` | Max agent turns per invocation |
118
+ | `agent.claude-code.flags` | Extra CLI flags (e.g. `["--dangerously-skip-permissions"]`) |
119
+ | `agent.claude-code.claudePath` | Path to `claude` binary (default: `"claude"`) |
120
+ | `session.turnLimit` | Pause session after N turns, ask user to /extend or /new |
121
+ | `workspacePath` | Absolute path to the assurgent repo root |
122
+
123
+ ## Environment Variables Passed to Agent
124
+
125
+ The wrapper sets these env vars on every Claude Code invocation:
126
+
127
+ | Variable | Description |
128
+ | ------------------ | ---------------------------------------------------- |
129
+ | `AGENT_SESSION_ID` | The Claude Code session UUID for the current session |
130
+
131
+ ## Development
132
+
133
+ ```bash
134
+ bun install # Install dependencies
135
+ bun run dev # Run the wrapper
136
+ bun run typecheck # Type check (tsc --noEmit)
137
+ bun test # Run tests
138
+ bun run lint # Lint (biome)
139
+ bun run lint:fix # Auto-fix lint issues
140
+ ```
141
+
142
+ ## Architecture
143
+
144
+ ```
145
+ ChatAdapter (Telegram) --> Wrapper Core --> AgentAdapter (Claude Code CLI)
146
+ |
147
+ Session Manager
148
+ (name <-> UUID)
149
+ ```
150
+
151
+ Both the chat side and agent side are pluggable interfaces. The design supports adding other chat platforms or agent backends later without touching the core.
152
+
153
+ ### Directory Structure
154
+
155
+ ```
156
+ app/
157
+ ├── src/
158
+ │ ├── index.ts # Entry point
159
+ │ ├── config.ts # Config loader + validation
160
+ │ ├── core/
161
+ │ │ ├── wrapper.ts # Core orchestrator
162
+ │ │ └── session-manager.ts
163
+ │ ├── interfaces/
164
+ │ │ ├── chat-adapter.ts
165
+ │ │ └── agent-adapter.ts
166
+ │ ├── chat/
167
+ │ │ └── telegram.ts # Telegram adapter (grammy)
168
+ │ └── agent/
169
+ │ └── claude-code.ts # Claude Code adapter (execa)
170
+ ├── config.json # Runtime config (gitignored)
171
+ ├── config.example.json # Committed template
172
+ ├── package.json
173
+ ├── tsconfig.json
174
+ └── biome.json
175
+ ```
package/cli.ts ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bun
2
+ // Entry point for `bunx assurgent`
3
+
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const args = process.argv.slice(2);
8
+
9
+ if (args.includes("--help") || args.includes("-h")) {
10
+ console.log(`
11
+ assurgent - Telegram bot bridge to Claude Code CLI
12
+
13
+ Usage:
14
+ assurgent Start the bot
15
+ assurgent init Scaffold config.json into ASSURGENT_HOME
16
+ assurgent --help Show this help message
17
+ assurgent --version Print the version number
18
+
19
+ Configuration:
20
+ Config is loaded from $ASSURGENT_HOME/config.json.
21
+ ASSURGENT_HOME defaults to ~/.assurgent/ if not set.
22
+
23
+ Run "assurgent init" to create the config file, then edit it with your settings.
24
+ `);
25
+ process.exit(0);
26
+ }
27
+
28
+ if (args.includes("--version") || args.includes("-v")) {
29
+ const pkgPath = join(import.meta.dirname, "package.json");
30
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
31
+ console.log(pkg.version);
32
+ process.exit(0);
33
+ }
34
+
35
+ if (args[0] === "init") {
36
+ const { getAssurgentHome } = await import("./src/config.ts");
37
+ const target = join(getAssurgentHome(), "config.json");
38
+
39
+ if (existsSync(target)) {
40
+ console.error(
41
+ `Config already exists at ${target}. Edit it directly or delete it to re-initialize.`,
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ mkdirSync(dirname(target), { recursive: true });
47
+ const source = join(import.meta.dirname, "config.example.json");
48
+ copyFileSync(source, target);
49
+ console.log(`Created config at ${target}. Edit it with your settings, then run "assurgent".`);
50
+ process.exit(0);
51
+ }
52
+
53
+ await import("./src/index.ts");
@@ -0,0 +1,26 @@
1
+ {
2
+ "chat": {
3
+ "adapter": "telegram",
4
+ "telegram": {
5
+ "botToken": "123456:ABC-DEF...",
6
+ "allowedUserIds": ["YOUR_TELEGRAM_USER_ID"],
7
+ "placeholder": {
8
+ "enabled": true,
9
+ "text": "thinking..."
10
+ }
11
+ }
12
+ },
13
+ "agent": {
14
+ "adapter": "claude-code",
15
+ "claude-code": {
16
+ "model": "sonnet",
17
+ "maxTurns": 10,
18
+ "flags": ["--dangerously-skip-permissions"],
19
+ "claudePath": "claude"
20
+ }
21
+ },
22
+ "session": {
23
+ "turnLimit": 20
24
+ },
25
+ "workspacePath": "/path/to/your/workspace"
26
+ }
package/package.json CHANGED
@@ -1,14 +1,28 @@
1
1
  {
2
2
  "name": "assurgent",
3
- "version": "0.0.1",
4
- "description": "",
5
- "main": "index.js",
6
- "types": "index.d.ts",
7
- "files": [
8
- "index.js",
9
- "index.d.ts"
10
- ],
11
- "keywords": [],
12
- "author": "",
13
- "license": "MIT"
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "assurgent": "./cli.ts"
7
+ },
8
+ "files": ["cli.ts", "src", "config.example.json", "README.md"],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "scripts": {
13
+ "dev": "bun run --watch src/index.ts",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "bun test",
16
+ "lint": "biome check .",
17
+ "lint:fix": "biome check . --fix"
18
+ },
19
+ "dependencies": {
20
+ "execa": "^9.5.2",
21
+ "grammy": "^1.35.0"
22
+ },
23
+ "devDependencies": {
24
+ "@biomejs/biome": "^1.9.4",
25
+ "@types/bun": "latest",
26
+ "typescript": "^5.8.2"
27
+ }
14
28
  }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ClaudeCodeConfig } from "./claude-code";
3
+ import { SUPPORTED_MODELS, buildArgs } from "./claude-code";
4
+
5
+ const baseConfig: ClaudeCodeConfig = {
6
+ model: "opus",
7
+ maxTurns: 10,
8
+ flags: [],
9
+ };
10
+
11
+ describe("SUPPORTED_MODELS", () => {
12
+ test("contains opus, sonnet, haiku", () => {
13
+ expect(SUPPORTED_MODELS).toContain("opus");
14
+ expect(SUPPORTED_MODELS).toContain("sonnet");
15
+ expect(SUPPORTED_MODELS).toContain("haiku");
16
+ });
17
+
18
+ test("has exactly 3 entries", () => {
19
+ expect(SUPPORTED_MODELS.length).toBe(3);
20
+ });
21
+ });
22
+
23
+ describe("buildArgs", () => {
24
+ test("always includes -p and --output-format json", () => {
25
+ const args = buildArgs(baseConfig, { message: "hello" });
26
+ expect(args).toContain("-p");
27
+ expect(args).toContain("--output-format");
28
+ expect(args).toContain("json");
29
+ });
30
+
31
+ test("includes --max-turns from config when not overridden", () => {
32
+ const args = buildArgs(baseConfig, { message: "hello" });
33
+ const idx = args.indexOf("--max-turns");
34
+ expect(idx).not.toBe(-1);
35
+ expect(args[idx + 1]).toBe("10");
36
+ });
37
+
38
+ test("includes --resume when sessionId is provided", () => {
39
+ const args = buildArgs(baseConfig, {
40
+ message: "hello",
41
+ sessionId: "9b55c171-cee9-4883-a365-abf385761889",
42
+ });
43
+ const idx = args.indexOf("--resume");
44
+ expect(idx).not.toBe(-1);
45
+ expect(args[idx + 1]).toBe("9b55c171-cee9-4883-a365-abf385761889");
46
+ });
47
+
48
+ test("does not include --resume when sessionId is absent", () => {
49
+ const args = buildArgs(baseConfig, { message: "hello" });
50
+ expect(args).not.toContain("--resume");
51
+ });
52
+
53
+ test("options.model overrides config.model", () => {
54
+ const args = buildArgs(baseConfig, { message: "hello", model: "sonnet" });
55
+ const idx = args.indexOf("--model");
56
+ expect(idx).not.toBe(-1);
57
+ expect(args[idx + 1]).toBe("sonnet");
58
+ expect(args.filter((a) => a === "--model").length).toBe(1);
59
+ });
60
+
61
+ test("includes --append-system-prompt when appendPrompt is set", () => {
62
+ const args = buildArgs(baseConfig, {
63
+ message: "hello",
64
+ appendPrompt: "Be concise.",
65
+ });
66
+ const idx = args.indexOf("--append-system-prompt");
67
+ expect(idx).not.toBe(-1);
68
+ expect(args[idx + 1]).toBe("Be concise.");
69
+ });
70
+
71
+ test("includes config flags", () => {
72
+ const config: ClaudeCodeConfig = {
73
+ ...baseConfig,
74
+ flags: ["--dangerously-skip-permissions"],
75
+ };
76
+ const args = buildArgs(config, { message: "hello" });
77
+ expect(args).toContain("--dangerously-skip-permissions");
78
+ });
79
+
80
+ test("message is always the last argument", () => {
81
+ const args = buildArgs(baseConfig, {
82
+ message: "do the thing",
83
+ sessionId: "abc-123",
84
+ appendPrompt: "extra",
85
+ });
86
+ expect(args[args.length - 1]).toBe("do the thing");
87
+ });
88
+ });
@@ -0,0 +1,79 @@
1
+ import { execa } from "execa";
2
+ import type { AgentAdapter, AgentInvokeOptions, AgentResponse } from "../interfaces/agent-adapter";
3
+
4
+ export const SUPPORTED_MODELS = ["opus", "sonnet", "haiku"] as const;
5
+
6
+ export interface ClaudeCodeConfig {
7
+ model: string;
8
+ maxTurns: number;
9
+ flags: string[];
10
+ claudePath?: string;
11
+ }
12
+
13
+ /**
14
+ * Builds the CLI argument array for `claude -p` invocations.
15
+ * Pure function -- no side effects.
16
+ */
17
+ export function buildArgs(config: ClaudeCodeConfig, options: AgentInvokeOptions): string[] {
18
+ const args: string[] = [
19
+ "-p",
20
+ "--output-format",
21
+ "json",
22
+ "--max-turns",
23
+ String(options.maxTurns ?? config.maxTurns),
24
+ ];
25
+
26
+ // Spread configured flags (e.g. --dangerously-skip-permissions)
27
+ args.push(...config.flags);
28
+
29
+ if (options.sessionId) {
30
+ args.push("--resume", options.sessionId);
31
+ }
32
+
33
+ const model = options.model ?? config.model;
34
+ if (model) {
35
+ args.push("--model", model);
36
+ }
37
+
38
+ if (options.appendPrompt) {
39
+ args.push("--append-system-prompt", options.appendPrompt);
40
+ }
41
+
42
+ // Message must always be the last argument
43
+ args.push(options.message);
44
+
45
+ return args;
46
+ }
47
+
48
+ /** AgentAdapter implementation backed by the Claude Code CLI. */
49
+ export class ClaudeCodeAdapter implements AgentAdapter {
50
+ readonly name = "claude-code";
51
+
52
+ constructor(
53
+ private config: ClaudeCodeConfig,
54
+ private workspacePath: string,
55
+ ) {}
56
+
57
+ async invoke(options: AgentInvokeOptions): Promise<AgentResponse> {
58
+ const args = buildArgs(this.config, options);
59
+
60
+ const proc = await execa(this.config.claudePath ?? "claude", args, {
61
+ cwd: this.workspacePath,
62
+ timeout: 180_000,
63
+ stdin: "ignore",
64
+ env: {
65
+ ...process.env,
66
+ AGENT_SESSION_ID: options.sessionId ?? "",
67
+ },
68
+ });
69
+
70
+ const raw = JSON.parse(proc.stdout) as Record<string, unknown>;
71
+
72
+ return {
73
+ result: (raw.result as string) ?? "",
74
+ sessionId: (raw.session_id as string) ?? "",
75
+ durationMs: (raw.duration_ms as number) ?? 0,
76
+ raw,
77
+ };
78
+ }
79
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { splitMessage } from "./telegram";
3
+
4
+ describe("splitMessage", () => {
5
+ test("returns single chunk when text is under limit", () => {
6
+ const result = splitMessage("hello world", 100);
7
+ expect(result).toEqual(["hello world"]);
8
+ });
9
+
10
+ test("splits at last newline before maxLength", () => {
11
+ const text = "line one\nline two\nline three";
12
+ const result = splitMessage(text, 18);
13
+ // "line one\nline two" is 17 chars, fits
14
+ expect(result[0]).toBe("line one\nline two");
15
+ expect(result[1]).toBe("line three");
16
+ });
17
+
18
+ test("hard splits at maxLength when no newlines present", () => {
19
+ const text = "abcdefghij"; // 10 chars
20
+ const result = splitMessage(text, 5);
21
+ expect(result[0]).toBe("abcde");
22
+ expect(result[1]).toBe("fghij");
23
+ });
24
+
25
+ test("handles multiple chunks", () => {
26
+ const text = "aaa\nbbb\nccc\nddd\neee";
27
+ const result = splitMessage(text, 8);
28
+ // "aaa\nbbb" is 7 chars
29
+ expect(result.length).toBeGreaterThanOrEqual(2);
30
+ for (const chunk of result) {
31
+ expect(chunk.length).toBeLessThanOrEqual(8);
32
+ }
33
+ });
34
+
35
+ test("returns empty array element for empty string", () => {
36
+ const result = splitMessage("", 100);
37
+ expect(result).toEqual([""]);
38
+ });
39
+ });
@@ -0,0 +1,135 @@
1
+ import { Bot } from "grammy";
2
+ import type { Context } from "grammy";
3
+ import type { ChatAdapter, IncomingMessage } from "../interfaces/chat-adapter";
4
+
5
+ /**
6
+ * Splits a text string into chunks of at most maxLength characters.
7
+ * Prefers splitting at the last newline before maxLength.
8
+ * If no newline is found, splits at maxLength.
9
+ * Trims leading whitespace from subsequent chunks.
10
+ */
11
+ export function splitMessage(text: string, maxLength: number): string[] {
12
+ if (text.length <= maxLength) return [text];
13
+
14
+ const chunks: string[] = [];
15
+ let remaining = text;
16
+
17
+ while (remaining.length > 0) {
18
+ if (remaining.length <= maxLength) {
19
+ chunks.push(remaining);
20
+ break;
21
+ }
22
+
23
+ let splitAt = remaining.lastIndexOf("\n", maxLength);
24
+ if (splitAt <= 0) splitAt = maxLength;
25
+
26
+ chunks.push(remaining.slice(0, splitAt));
27
+ remaining = remaining.slice(splitAt).trimStart();
28
+ }
29
+
30
+ return chunks;
31
+ }
32
+
33
+ export class TelegramAdapter implements ChatAdapter {
34
+ private bot: Bot;
35
+ private messageHandler?: (msg: IncomingMessage) => Promise<void>;
36
+ private commandHandlers = new Map<
37
+ string,
38
+ (msg: IncomingMessage, args: string) => Promise<void>
39
+ >();
40
+
41
+ constructor(
42
+ private config: {
43
+ botToken: string;
44
+ allowedUserIds: string[];
45
+ placeholder?: { enabled: boolean; text: string };
46
+ },
47
+ ) {
48
+ this.bot = new Bot(config.botToken);
49
+ }
50
+
51
+ async start(): Promise<void> {
52
+ for (const [cmd, handler] of this.commandHandlers) {
53
+ this.bot.command(cmd, async (ctx) => {
54
+ if (!this.isAllowed(ctx.from?.id)) return;
55
+ const msg = this.toIncoming(ctx);
56
+ await handler(msg, ctx.match ?? "");
57
+ });
58
+ }
59
+
60
+ this.bot.on("message:text", async (ctx) => {
61
+ if (!this.isAllowed(ctx.from?.id)) return;
62
+ if (ctx.message?.text?.startsWith("/")) return;
63
+ if (this.messageHandler) {
64
+ await this.messageHandler(this.toIncoming(ctx));
65
+ }
66
+ });
67
+
68
+ this.bot.start();
69
+ }
70
+
71
+ async stop(): Promise<void> {
72
+ await this.bot.stop();
73
+ }
74
+
75
+ onMessage(handler: (msg: IncomingMessage) => Promise<void>): void {
76
+ this.messageHandler = handler;
77
+ }
78
+
79
+ onCommand(command: string, handler: (msg: IncomingMessage, args: string) => Promise<void>): void {
80
+ this.commandHandlers.set(command, handler);
81
+ }
82
+
83
+ async sendText(chatId: string, text: string): Promise<void> {
84
+ const chunks = splitMessage(text, 4000);
85
+ for (const chunk of chunks) {
86
+ try {
87
+ await this.bot.api.sendMessage(Number(chatId), chunk, { parse_mode: "Markdown" });
88
+ } catch {
89
+ await this.bot.api.sendMessage(Number(chatId), chunk);
90
+ }
91
+ }
92
+ }
93
+
94
+ async sendTyping(chatId: string): Promise<void> {
95
+ await this.bot.api.sendChatAction(Number(chatId), "typing");
96
+ }
97
+
98
+ async sendPlaceholder(chatId: string): Promise<number | undefined> {
99
+ if (!this.config.placeholder?.enabled) return undefined;
100
+ const msg = await this.bot.api.sendMessage(Number(chatId), this.config.placeholder.text);
101
+ return msg.message_id;
102
+ }
103
+
104
+ async editMessage(chatId: string, messageId: number, text: string): Promise<void> {
105
+ const chunks = splitMessage(text, 4000);
106
+ try {
107
+ await this.bot.api.editMessageText(Number(chatId), messageId, chunks[0], {
108
+ parse_mode: "Markdown",
109
+ });
110
+ } catch {
111
+ await this.bot.api.editMessageText(Number(chatId), messageId, chunks[0]);
112
+ }
113
+ for (let i = 1; i < chunks.length; i++) {
114
+ try {
115
+ await this.bot.api.sendMessage(Number(chatId), chunks[i], { parse_mode: "Markdown" });
116
+ } catch {
117
+ await this.bot.api.sendMessage(Number(chatId), chunks[i]);
118
+ }
119
+ }
120
+ }
121
+
122
+ private isAllowed(userId?: number): boolean {
123
+ if (!userId) return false;
124
+ return this.config.allowedUserIds.includes(String(userId));
125
+ }
126
+
127
+ private toIncoming(ctx: Context): IncomingMessage {
128
+ return {
129
+ chatId: String(ctx.chat?.id ?? ""),
130
+ text: ctx.message?.text ?? "",
131
+ from: String(ctx.from?.id ?? ""),
132
+ timestamp: ctx.message?.date ?? Date.now(),
133
+ };
134
+ }
135
+ }