morphe-ai 0.0.0-stage → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Morphe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,125 @@
1
- # Temporary Holding Version
1
+ # morphe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Morphe in your terminal. Say what you want; the agent reads, writes and runs things in the folder you are in. You bring the model: a key for Claude, GPT, Gemini, Groq, DeepSeek, xAI or OpenRouter, a free local model through Ollama or LM Studio, or any server that speaks the OpenAI chat protocol. Your key is stored on your machine and sent only to the provider you chose.
4
+
5
+ `morphe page` is the website in a shell: one sentence in, a working `index.html` out.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i -g morphe-ai
11
+ ```
12
+
13
+ Also works with `pnpm add -g morphe-ai`, `yarn global add morphe-ai` and `bun add -g morphe-ai`. Node.js 18 or newer.
14
+
15
+ No Node.js? On Windows, grab `morphe-windows-x64.zip` from the releases page, unzip it and run `morphe.exe` (put it on your PATH to call it from anywhere).
16
+
17
+ ## First five minutes
18
+
19
+ ```bash
20
+ morphe setup # choose an endpoint, paste your key, pick a default model
21
+ cd some-project
22
+ morphe # chat; the agent works in this folder
23
+ ```
24
+
25
+ One task and done:
26
+
27
+ ```bash
28
+ morphe do "add a dark-mode switch to the header"
29
+ ```
30
+
31
+ A page from a sentence, exactly like the site:
32
+
33
+ ```bash
34
+ mkdir cafe && cd cafe
35
+ morphe page "a menu page for a small café: prices, a few photos, opening hours"
36
+ # -> index.html (open it in a browser)
37
+ ```
38
+
39
+ ## Models
40
+
41
+ A model is written `endpoint/model-id`:
42
+
43
+ ```bash
44
+ morphe models # everything your keys can reach
45
+ morphe use anthropic/claude-opus-5-5 # default for every run
46
+ morphe -m groq/llama-3.3-70b-versatile # just this run
47
+ ```
48
+
49
+ Built-in endpoints: `anthropic`, `openai`, `openrouter`, `gemini`, `groq`, `deepseek`, `xai`, `ollama`, `lmstudio`. Keys may also come from the usual environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `XAI_API_KEY`).
50
+
51
+ Any other OpenAI-compatible server: choose **custom** in `morphe setup`, or add it to `~/.morphe/settings.json`:
52
+
53
+ ```json
54
+ {
55
+ "model": "together/meta-llama/Llama-3.3-70B-Instruct-Turbo",
56
+ "endpoints": {
57
+ "together": { "kind": "openai", "url": "https://api.together.xyz/v1", "key": "…" }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Modes
63
+
64
+ | Mode | What it may do |
65
+ |---------|----------------|
66
+ | `build` | Default. List, view, search, write, patch, run shell commands. |
67
+ | `page` | Build or edit a single `index.html`, like the website. Files only, never commands. |
68
+ | `plan` | Look around and write a plan. Changes nothing. |
69
+
70
+ Pick one with `-M page`, or `:mode page` inside a chat. Your own modes are small JSON files in `~/.morphe/modes/` (everywhere) or `./.morphe/modes/` (this project):
71
+
72
+ ```json
73
+ {
74
+ "about": "Reviews code and points out problems",
75
+ "tools": ["ls", "view", "glob", "grep"],
76
+ "model": "anthropic/claude-sonnet-5-5",
77
+ "system": "You are a careful reviewer. Report bugs, risky code and unclear names. Do not change files."
78
+ }
79
+ ```
80
+
81
+ Then `morphe -M reviewer`.
82
+
83
+ **Tools:** `ls`, `view`, `glob`, `grep`, `write`, `patch`, `shell`. Every path is kept inside the project folder.
84
+
85
+ ## Project notes
86
+
87
+ A `MORPHE.md` (or `AGENTS.md`) in the project root is read at the start of every chat. Put your stack, conventions and the commands to build and test there.
88
+
89
+ ## Asking before acting
90
+
91
+ By default Morphe asks before it writes a file or runs a command. Answer `a` to allow that kind of action for the rest of the chat, pass `--auto` to skip the questions, or set it once in settings:
92
+
93
+ ```json
94
+ { "approve": { "write": "ask", "shell": "ask" } }
95
+ ```
96
+
97
+ Values: `ask`, `allow`, `deny`. `morphe page` always writes without asking; that is its whole job.
98
+
99
+ ## Settings (`~/.morphe/settings.json`)
100
+
101
+ | Key | Meaning |
102
+ |-----|---------|
103
+ | `model` | default `endpoint/model-id` |
104
+ | `mode` | default mode (`build`) |
105
+ | `effort` | Claude only: `low`, `medium`, `high`, `xhigh`, `max` |
106
+ | `approve` | `{ "write": …, "shell": … }` |
107
+ | `endpoints` | your keys and custom URLs |
108
+
109
+ A project may override anything except keys in `./.morphe/settings.json`. Every chat is logged to `./.morphe/sessions/*.jsonl` so you can see later what was done.
110
+
111
+ ## Inside a chat
112
+
113
+ `:model <ref>` · `:mode <name>` · `:models` · `:modes` · `:reset` · `:auto` · `:usage` · `:quit`
114
+
115
+ ## Building the executables
116
+
117
+ ```bash
118
+ cd cli && npm install
119
+ bun scripts/build.mjs windows-x64 # dist/morphe-windows-x64/morphe.exe + .zip + SHA256SUMS
120
+ bun scripts/build.mjs # all targets
121
+ ```
122
+
123
+ ## License
124
+
125
+ MIT.
package/bin/morphe.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/index.js';
3
+
4
+ run(process.argv.slice(2)).then(
5
+ (code) => process.exit(code || 0),
6
+ (err) => {
7
+ console.error(`\n ✗ ${err?.message || err}`);
8
+ process.exit(1);
9
+ },
10
+ );
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
- {
2
- "name": "morphe-ai",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "morphe-ai",
3
+ "version": "0.2.1",
4
+ "description": "Morphe in your terminal. Say what you want built and the agent makes it in your project folder, on whichever model you bring: Claude, GPT, Gemini, Groq, DeepSeek, xAI, OpenRouter, or a local Ollama / LM Studio model.",
5
+ "type": "module",
6
+ "bin": {
7
+ "morphe": "bin/morphe.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "bun scripts/build.mjs",
17
+ "build:win": "bun scripts/build.mjs windows-x64",
18
+ "start": "node bin/morphe.js"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "morphe",
25
+ "ai",
26
+ "agent",
27
+ "cli",
28
+ "coding-agent",
29
+ "web-builder",
30
+ "claude",
31
+ "byok",
32
+ "vibe-coding"
33
+ ],
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@anthropic-ai/sdk": "^1.1.0",
37
+ "openai": "^7.39.0"
38
+ },
39
+ "author": "Morphe crew",
40
+ "homepage": "https://morpheagent.xyz"
41
+ }
package/src/agent.js ADDED
@@ -0,0 +1,73 @@
1
+ // The loop: ask the model, run what it asks for, feed the results back, until it stops.
2
+ // Provider differences are hidden behind the two small clients in ./llm.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { endpoint, splitModel, LOCAL_DIR } from './config.js';
6
+ import { anthropicClient, anthropicModels } from './llm/anthropic.js';
7
+ import { openaiClient, openaiModels } from './llm/openai.js';
8
+ import { systemPrompt } from './modes.js';
9
+ import { TOOL_DEFS, describe, execute } from './tools.js';
10
+
11
+ const MAX_STEPS = 50;
12
+
13
+ export function listModels(ep) {
14
+ return ep.kind === 'anthropic' ? anthropicModels(ep) : openaiModels(ep);
15
+ }
16
+
17
+ /**
18
+ * A conversation with one model in one mode. `events`: { text, thinking, tool(name,input), done(usage), note(msg) }.
19
+ * `approve(kind, what)` decides writes and shell commands.
20
+ */
21
+ export function createAgent({ settings, mode, modeName, modelRef, cwd = process.cwd(), approve }) {
22
+ const { endpoint: epName, model } = splitModel(modelRef);
23
+ const ep = endpoint(settings, epName);
24
+ const tools = TOOL_DEFS.filter((t) => mode.tools.includes(t.name));
25
+ const allowed = new Set(mode.tools);
26
+ const system = systemPrompt(mode, cwd);
27
+ const client = ep.kind === 'anthropic' ? anthropicClient(ep, model, { system, tools, effort: settings.effort }) : openaiClient(ep, model, { system, tools });
28
+ const history = [];
29
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
30
+ const transcript = openTranscript(cwd, modeName, modelRef);
31
+
32
+ async function send(text, events, signal) {
33
+ history.push(client.user(text));
34
+ transcript?.write({ role: 'user', text });
35
+ for (let step = 0; step < MAX_STEPS; step++) {
36
+ const r = await client.step(history, events, signal);
37
+ for (const k of Object.keys(totals)) totals[k] += r.usage[k] || 0;
38
+ if (r.stop === 'refusal') { events.note?.('The model declined this request. Try putting it differently.'); return; }
39
+ if (r.stop === 'pause_turn') { history.push(client.assistant(r)); continue; }
40
+ if (r.stop === 'max_tokens' && r.calls.length) { events.note?.('The reply was cut off mid tool call. Ask for the next part in smaller steps.'); return; }
41
+ history.push(client.assistant(r));
42
+ transcript?.write({ role: 'assistant', text: r.text, calls: r.calls.map((c) => c.name) });
43
+ if (!r.calls.length) return;
44
+ const results = [];
45
+ for (const call of r.calls) {
46
+ if (!call.input) { results.push({ id: call.id, text: 'The tool arguments were not valid JSON. Try again.', error: true }); continue; }
47
+ events.tool?.(call.name, call.input);
48
+ const out = await execute(call.name, call.input, { root: cwd, allowed, approve });
49
+ if (out.error) events.toolError?.(out.text);
50
+ results.push({ id: call.id, ...out });
51
+ transcript?.write({ role: 'tool', name: call.name, summary: describe(call.name, call.input), error: !!out.error });
52
+ }
53
+ const block = client.results(results);
54
+ Array.isArray(block) ? history.push(...block) : history.push(block);
55
+ }
56
+ events.note?.(`Stopped after ${MAX_STEPS} steps. Say "continue" to keep going.`);
57
+ }
58
+
59
+ return { send, totals, modelRef, modeName, endpointName: epName };
60
+ }
61
+
62
+ /** Every session is logged to .morphe/sessions/<timestamp>.jsonl so you can see what happened later. */
63
+ function openTranscript(cwd, mode, model) {
64
+ try {
65
+ const dir = path.join(cwd, LOCAL_DIR, 'sessions');
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ const file = path.join(dir, new Date().toISOString().replace(/[:.]/g, '-') + '.jsonl');
68
+ fs.appendFileSync(file, JSON.stringify({ started: new Date().toISOString(), mode, model }) + '\n');
69
+ return { file, write: (e) => fs.appendFileSync(file, JSON.stringify({ t: Date.now(), ...e }) + '\n') };
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
package/src/config.js ADDED
@@ -0,0 +1,66 @@
1
+ // Where Morphe keeps its keys and preferences: ~/.morphe/settings.json, with per-project
2
+ // overrides in ./.morphe/settings.json. Keys only ever live in the home file.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ export const HOME_DIR = process.env.MORPHE_HOME || path.join(os.homedir(), '.morphe');
8
+ export const SETTINGS = path.join(HOME_DIR, 'settings.json');
9
+ export const LOCAL_DIR = '.morphe';
10
+
11
+ /** Known providers. Anything speaking the OpenAI chat protocol can be added as a custom endpoint. */
12
+ export const PROVIDERS = {
13
+ anthropic: { kind: 'anthropic', title: 'Anthropic (Claude)', envKey: 'ANTHROPIC_API_KEY', suggest: 'claude-opus-5-5' },
14
+ openai: { kind: 'openai', title: 'OpenAI', url: 'https://api.openai.com/v1', envKey: 'OPENAI_API_KEY' },
15
+ openrouter: { kind: 'openai', title: 'OpenRouter', url: 'https://openrouter.ai/api/v1', envKey: 'OPENROUTER_API_KEY' },
16
+ gemini: { kind: 'openai', title: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/', envKey: 'GEMINI_API_KEY' },
17
+ groq: { kind: 'openai', title: 'Groq', url: 'https://api.groq.com/openai/v1', envKey: 'GROQ_API_KEY' },
18
+ deepseek: { kind: 'openai', title: 'DeepSeek', url: 'https://api.deepseek.com/v1', envKey: 'DEEPSEEK_API_KEY' },
19
+ xai: { kind: 'openai', title: 'xAI (Grok)', url: 'https://api.x.ai/v1', envKey: 'XAI_API_KEY' },
20
+ ollama: { kind: 'openai', title: 'Ollama (local)', url: 'http://localhost:11434/v1', keyless: true },
21
+ lmstudio: { kind: 'openai', title: 'LM Studio (local)', url: 'http://localhost:1234/v1', keyless: true },
22
+ };
23
+
24
+ const BASE = { model: '', mode: 'build', effort: '', approve: { write: 'ask', shell: 'ask' }, endpoints: {} };
25
+
26
+ function readJson(file) {
27
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
28
+ catch (e) { if (e.code === 'ENOENT') return {}; throw new Error(`${file} is not valid JSON: ${e.message}`); }
29
+ }
30
+
31
+ export const loadHome = () => ({ ...structuredClone(BASE), ...readJson(SETTINGS) });
32
+
33
+ export function loadSettings(cwd = process.cwd()) {
34
+ const home = loadHome();
35
+ const local = readJson(path.join(cwd, LOCAL_DIR, 'settings.json'));
36
+ return { ...home, ...local, approve: { ...home.approve, ...local.approve }, endpoints: { ...home.endpoints, ...local.endpoints } };
37
+ }
38
+
39
+ export function saveHome(settings) {
40
+ fs.mkdirSync(HOME_DIR, { recursive: true });
41
+ fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
42
+ }
43
+
44
+ /** "groq/llama-3.3-70b" -> { endpoint: "groq", model: "llama-3.3-70b" }. Model ids may contain slashes. */
45
+ export function splitModel(ref) {
46
+ const i = (ref || '').indexOf('/');
47
+ if (i < 1) throw new Error(`A model is written as endpoint/model-id, for example anthropic/claude-opus-5-5 (got "${ref || ''}").`);
48
+ return { endpoint: ref.slice(0, i), model: ref.slice(i + 1) };
49
+ }
50
+
51
+ /** Everything needed to talk to one endpoint: kind, url, key. */
52
+ export function endpoint(settings, name) {
53
+ const known = PROVIDERS[name];
54
+ const own = settings.endpoints?.[name];
55
+ if (!known && !own) throw new Error(`Unknown endpoint "${name}". Run "morphe setup" to add it.`);
56
+ const e = { name, kind: 'openai', ...known, ...own };
57
+ e.key = own?.key || (e.envKey && process.env[e.envKey]) || '';
58
+ if (!e.key && !e.keyless) throw new Error(`No key for "${name}". Run "morphe setup" or set ${e.envKey || 'a key in settings.json'}.`);
59
+ return e;
60
+ }
61
+
62
+ export function hideKeys(settings) {
63
+ const c = structuredClone(settings);
64
+ for (const e of Object.values(c.endpoints || {})) if (e.key) e.key = e.key.slice(0, 5) + '…' + e.key.slice(-3);
65
+ return c;
66
+ }
package/src/index.js ADDED
@@ -0,0 +1,224 @@
1
+ // morphe: the command line. Parses arguments, runs commands, hosts the chat.
2
+ import { PROVIDERS, SETTINGS, endpoint, hideKeys, loadHome, loadSettings, saveHome, splitModel } from './config.js';
3
+ import { createAgent, listModels } from './agent.js';
4
+ import { loadModes } from './modes.js';
5
+ import { describe } from './tools.js';
6
+ import { LOGO, bad, faint, ok, orange, prompter, sky, spinner, strong } from './ui.js';
7
+ import { VERSION } from './version.js';
8
+
9
+ const USAGE = `${strong('morphe')} ${faint('v' + VERSION)} · say it, launch it
10
+
11
+ ${strong('Commands')}
12
+ morphe chat with the agent in this folder
13
+ morphe do "<task>" one task, then exit
14
+ morphe page "<what>" build index.html from one sentence (like the website)
15
+ morphe setup add an endpoint and your own key
16
+ morphe forget <endpoint> remove a saved key
17
+ morphe models [endpoint] what your keys can use
18
+ morphe use <endpoint/model> set the default model
19
+ morphe modes list modes (build, page, plan, yours)
20
+ morphe settings show settings (keys hidden)
21
+
22
+ ${strong('Flags')}
23
+ -m, --model <endpoint/model> model for this run
24
+ -M, --mode <name> mode for this run
25
+ --auto never ask before writing files or running commands
26
+ -h, --help · -v, --version
27
+
28
+ ${strong('In chat')}
29
+ :model <ref> :mode <name> :models :modes :reset :auto :usage :quit
30
+
31
+ Settings: ${SETTINGS}. Custom modes: ~/.morphe/modes/*.json or ./.morphe/modes/*.json. Project notes: MORPHE.md.`;
32
+
33
+ export async function run(argv) {
34
+ const flags = { auto: false, model: '', mode: '' };
35
+ const rest = [];
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ if (a === '--auto') flags.auto = true;
39
+ else if (a === '-m' || a === '--model') flags.model = argv[++i] || '';
40
+ else if (a === '-M' || a === '--mode') flags.mode = argv[++i] || '';
41
+ else if (a === '-h' || a === '--help') return void console.log(USAGE);
42
+ else if (a === '-v' || a === '--version') return void console.log(VERSION);
43
+ else rest.push(a);
44
+ }
45
+ const [cmd, ...args] = rest;
46
+ switch (cmd) {
47
+ case undefined: return chat(flags);
48
+ case 'do': return chat({ ...flags, task: args.join(' ') });
49
+ case 'page': return chat({ ...flags, mode: flags.mode || 'page', auto: true, task: args.join(' ') });
50
+ case 'setup': return setup();
51
+ case 'forget': return forget(args[0]);
52
+ case 'models': return models(args[0]);
53
+ case 'use': return use(args[0]);
54
+ case 'modes': return modes();
55
+ case 'settings': return void console.log(faint(SETTINGS) + '\n' + JSON.stringify(hideKeys(loadSettings()), null, 2));
56
+ case 'help': return void console.log(USAGE);
57
+ default: throw new Error(`"${cmd}" is not a command. For a task, write: morphe do "${cmd} ${args.join(' ')}".`);
58
+ }
59
+ }
60
+
61
+ /* ---------------------------------------------------------------- setup */
62
+ async function setup() {
63
+ const p = prompter();
64
+ try {
65
+ console.log(LOGO);
66
+ console.log(strong(' Add an endpoint.') + faint(' Your key stays in ' + SETTINGS + ' on this machine.\n'));
67
+ const names = Object.keys(PROVIDERS);
68
+ names.forEach((n, i) => console.log(` ${orange(String(i + 1).padStart(2))} ${n.padEnd(11)} ${faint(PROVIDERS[n].title)}`));
69
+ console.log(` ${orange(String(names.length + 1).padStart(2))} ${'custom'.padEnd(11)} ${faint('any OpenAI-compatible URL')}`);
70
+ const n = Number(await p.ask('\n which one? '));
71
+ if (!(n >= 1 && n <= names.length + 1)) throw new Error('Pick a number from the list.');
72
+ const home = loadHome();
73
+ let name;
74
+ const entry = {};
75
+ if (n === names.length + 1) {
76
+ name = (await p.ask(' name for it (letters, numbers, -): ')).toLowerCase();
77
+ if (!/^[a-z0-9_-]+$/.test(name)) throw new Error('Letters, numbers, - or _ only.');
78
+ entry.kind = 'openai';
79
+ entry.url = await p.ask(' base URL (e.g. https://api.together.xyz/v1): ');
80
+ } else name = names[n - 1];
81
+ const known = PROVIDERS[name] || {};
82
+ if (!known.keyless) {
83
+ const key = await p.secret(` API key for ${name}${known.envKey ? faint(` (empty = use $${known.envKey})`) : ''}: `);
84
+ if (key) entry.key = key;
85
+ }
86
+ home.endpoints[name] = { ...home.endpoints[name], ...entry };
87
+
88
+ const sp = spinner('checking the connection…');
89
+ let ids = [];
90
+ try { ids = await listModels(endpoint(home, name)); sp.stop(); console.log(ok(` ✓ connected, ${ids.length} models`)); }
91
+ catch (e) { sp.stop(); console.log(bad(' ✗ ' + reason(e))); if ((await p.ask(' save anyway? [y/N] ')).toLowerCase() !== 'y') return; }
92
+ const first = ids.includes(known.suggest) ? known.suggest : known.suggest || ids[0];
93
+ if (ids.length) { console.log(faint('\n models:')); for (const id of ids.slice(0, 20)) console.log(' ' + id); if (ids.length > 20) console.log(faint(` …${ids.length - 20} more (morphe models ${name})`)); }
94
+ const model = (await p.ask(`\n default model${first ? faint(` [${first}]`) : ''}: `)) || first;
95
+ if (model) home.model = `${name}/${model}`;
96
+ saveHome(home);
97
+ console.log(ok(`\n ✓ saved ${name}`) + (model ? ` · default model ${sky(home.model)}` : ''));
98
+ console.log(faint(' run `morphe` in any project folder to start.'));
99
+ } finally { p.close(); }
100
+ }
101
+
102
+ function forget(name) {
103
+ if (!name) throw new Error('Usage: morphe forget <endpoint>');
104
+ const home = loadHome();
105
+ if (!home.endpoints[name]) throw new Error(`Nothing saved for "${name}".`);
106
+ delete home.endpoints[name];
107
+ if (home.model.startsWith(name + '/')) home.model = '';
108
+ saveHome(home);
109
+ console.log(ok(` ✓ forgot ${name}`));
110
+ }
111
+
112
+ async function models(only) {
113
+ const settings = loadSettings();
114
+ const names = only ? [only] : Object.keys(settings.endpoints).concat(Object.keys(PROVIDERS).filter((n) => PROVIDERS[n].envKey && process.env[PROVIDERS[n].envKey] && !settings.endpoints[n]));
115
+ if (!names.length) return console.log(' No endpoints yet. Run `morphe setup`.');
116
+ for (const name of names) {
117
+ console.log(strong(' ' + name));
118
+ try { for (const id of await listModels(endpoint(settings, name))) { const ref = `${name}/${id}`; console.log(ref === settings.model ? orange(` ● ${ref}`) : ` ${ref}`); } }
119
+ catch (e) { console.log(bad(' ' + reason(e))); }
120
+ }
121
+ }
122
+
123
+ function use(ref) {
124
+ if (!ref) throw new Error('Usage: morphe use <endpoint/model>');
125
+ const home = loadHome();
126
+ endpoint(home, splitModel(ref).endpoint);
127
+ home.model = ref;
128
+ saveHome(home);
129
+ console.log(ok(` ✓ default model: ${ref}`));
130
+ }
131
+
132
+ function modes() {
133
+ for (const [name, m] of Object.entries(loadModes())) console.log(` ${orange(name.padEnd(10))} ${m.about}${m.model ? faint(` · ${m.model}`) : ''}${m.file ? faint(` · ${m.file}`) : ''}`);
134
+ }
135
+
136
+ const reason = (e) => (e?.status === 401 ? 'the key was rejected (401)' : e?.status ? `${e.status} ${e.message}` : e?.cause?.code === 'ECONNREFUSED' ? `nothing is listening at ${e.cause.address}:${e.cause.port}` : e?.message || String(e));
137
+
138
+ /* ---------------------------------------------------------------- chat */
139
+ async function chat(flags) {
140
+ const settings = loadSettings();
141
+ const allModes = loadModes();
142
+ let modeName = flags.mode || settings.mode || 'build';
143
+ if (!allModes[modeName]) throw new Error(`No mode called "${modeName}". See: morphe modes`);
144
+ let modelRef = flags.model || allModes[modeName].model || settings.model;
145
+ if (!modelRef) throw new Error('No model yet. Run `morphe setup` to add your own key, or pass -m endpoint/model.');
146
+
147
+ const gate = { ...settings.approve };
148
+ if (flags.auto) gate.write = gate.shell = 'allow';
149
+ const oneShot = flags.task !== undefined;
150
+ let p = null;
151
+ const ask = () => (p ||= prompter());
152
+
153
+ async function approve(kind, what) {
154
+ if (gate[kind] === 'allow') return true;
155
+ if (gate[kind] === 'deny' || !process.stdin.isTTY) return false;
156
+ const [head, ...body] = what.split('\n');
157
+ console.log('\n ' + orange('?') + ' ' + strong(head) + body.map((l) => '\n ' + (l.startsWith('+') ? ok(l) : l.startsWith('-') ? bad(l) : faint(l))).join(''));
158
+ const a = (await ask().ask(orange(' allow? ') + faint('[y]es / [n]o / [a]lways: '))).toLowerCase();
159
+ if (a === 'a') gate[kind] = 'allow';
160
+ return a === 'a' || a === 'y' || a === 'yes';
161
+ }
162
+
163
+ let agent = createAgent({ settings, mode: allModes[modeName], modeName, modelRef, approve });
164
+ const fresh = () => (agent = createAgent({ settings, mode: allModes[modeName], modeName, modelRef, approve }));
165
+
166
+ let col0 = true;
167
+ let spin = null;
168
+ const events = {
169
+ text(t) { spin?.stop(); spin = null; process.stdout.write(t); col0 = t.endsWith('\n'); },
170
+ thinking() { if (!spin) spin = spinner('thinking…'); },
171
+ tool(name, input) { spin?.stop(); spin = null; if (!col0) process.stdout.write('\n'); console.log(sky(' ▸ ') + faint(describe(name, input))); col0 = true; },
172
+ toolError(msg) { console.log(bad(' ✗ ' + msg.split('\n')[0].slice(0, 150))); },
173
+ note(msg) { spin?.stop(); spin = null; console.log('\n ' + orange('!') + ' ' + msg); col0 = true; },
174
+ };
175
+
176
+ async function task(text) {
177
+ spin = spinner('thinking…');
178
+ try { await agent.send(text, events); }
179
+ catch (e) { spin?.stop(); spin = null; console.log('\n ' + bad('✗ ' + reason(e))); if (oneShot) process.exitCode = 1; }
180
+ spin?.stop(); spin = null;
181
+ if (!col0) process.stdout.write('\n');
182
+ col0 = true;
183
+ }
184
+
185
+ if (oneShot) {
186
+ if (!flags.task) throw new Error('Tell me what to do: morphe do "<task>" or morphe page "<what>"');
187
+ await task(flags.task);
188
+ p?.close();
189
+ return process.exitCode;
190
+ }
191
+
192
+ ask();
193
+ console.log(LOGO);
194
+ const where = () => faint(` mode ${modeName} · model ${modelRef} · ${process.cwd()}`);
195
+ console.log(where());
196
+ console.log(faint(' say what you want. :help for commands, :quit to leave.\n'));
197
+
198
+ const commands = {
199
+ help: () => console.log(USAGE),
200
+ model: (arg) => { if (!arg) return console.log(where()); endpoint(settings, splitModel(arg).endpoint); modelRef = arg; fresh(); console.log(ok(` ✓ model ${arg}`) + faint(' (new conversation)')); },
201
+ mode: (arg) => { if (!arg) return console.log(where()); if (!allModes[arg]) throw new Error(`No mode "${arg}".`); modeName = arg; fresh(); console.log(ok(` ✓ mode ${arg}`) + faint(` · ${allModes[arg].about} (new conversation)`)); },
202
+ models: async () => { const n = splitModel(modelRef).endpoint; for (const id of await listModels(endpoint(settings, n))) console.log(` ${n}/${id}`); },
203
+ modes,
204
+ reset: () => { fresh(); console.log(ok(' ✓ new conversation')); },
205
+ auto: () => { const on = gate.write !== 'allow' || gate.shell !== 'allow'; gate.write = gate.shell = on ? 'allow' : 'ask'; console.log(on ? orange(' ⚡ auto: writes and commands run without asking') : ok(' ✓ asking again before writes and commands')); },
206
+ usage: () => { const t = agent.totals; console.log(faint(` tokens this conversation: ${t.input + t.cacheRead + t.cacheWrite} in (${t.cacheRead} from cache) · ${t.output} out`)); },
207
+ };
208
+
209
+ for (;;) {
210
+ const line = (await p.ask(orange(' › '))).trim();
211
+ if (!line) continue;
212
+ if (line === ':quit' || line === ':q' || line === ':exit') break;
213
+ if (line.startsWith(':')) {
214
+ const [name, ...a] = line.slice(1).split(/\s+/);
215
+ try { if (commands[name]) await commands[name](a.join(' ')); else console.log(bad(` no command :${name}. try :help`)); }
216
+ catch (e) { console.log(bad(' ✗ ' + reason(e))); }
217
+ continue;
218
+ }
219
+ await task(line);
220
+ console.log();
221
+ }
222
+ p.close();
223
+ return 0;
224
+ }
@@ -0,0 +1,42 @@
1
+ // Claude through the official SDK. One `step` = one model call; the agent loop lives in agent.js.
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+
4
+ const FALLBACK_OK = new Set(['claude-fable-5-1', 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-5-5']);
5
+
6
+ export function anthropicClient(ep, model, { system, tools, effort }) {
7
+ const client = new Anthropic({ apiKey: ep.key, baseURL: ep.url });
8
+ const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema, eager_input_streaming: true }));
9
+ const useFallback = !ep.url && FALLBACK_OK.has(model);
10
+
11
+ return {
12
+ /** messages: Anthropic-shaped history. Resolves { text, calls:[{id,name,input}], stop, usage, content }. */
13
+ async step(messages, events, signal) {
14
+ const params = { model, max_tokens: 64000, system, tools: toolDefs, messages, cache_control: { type: 'ephemeral' }, thinking: { type: 'adaptive', display: 'summarized' } };
15
+ if (effort) params.output_config = { effort };
16
+ const stream = useFallback
17
+ ? client.beta.messages.stream({ ...params, betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' }, { signal })
18
+ : client.messages.stream(params, { signal });
19
+ stream.on('text', (t) => events.text?.(t));
20
+ stream.on('thinking', (t) => events.thinking?.(t));
21
+ const msg = await stream.finalMessage();
22
+ const u = msg.usage || {};
23
+ return {
24
+ content: msg.content,
25
+ text: msg.content.filter((b) => b.type === 'text').map((b) => b.text).join(''),
26
+ calls: msg.content.filter((b) => b.type === 'tool_use').map((b) => ({ id: b.id, name: b.name, input: b.input })),
27
+ stop: msg.stop_reason,
28
+ usage: { input: u.input_tokens || 0, output: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0 },
29
+ };
30
+ },
31
+ assistant: (r) => ({ role: 'assistant', content: r.content }),
32
+ results: (list) => ({ role: 'user', content: list.map((x) => ({ type: 'tool_result', tool_use_id: x.id, content: x.text, ...(x.error && { is_error: true }) })) }),
33
+ user: (text) => ({ role: 'user', content: text }),
34
+ };
35
+ }
36
+
37
+ export async function anthropicModels(ep) {
38
+ const client = new Anthropic({ apiKey: ep.key, baseURL: ep.url });
39
+ const out = [];
40
+ for await (const m of client.models.list()) out.push(m.id);
41
+ return out;
42
+ }
@@ -0,0 +1,54 @@
1
+ // Any endpoint that speaks the OpenAI chat protocol: OpenAI, OpenRouter, Gemini, Groq, DeepSeek,
2
+ // xAI, Ollama, LM Studio, or a custom URL. Same `step` shape as the Anthropic client.
3
+ import OpenAI from 'openai';
4
+
5
+ const make = (ep) => new OpenAI({ apiKey: ep.key || 'none', baseURL: ep.url, defaultHeaders: { 'X-Title': 'Morphe CLI', ...ep.headers } });
6
+
7
+ export function openaiClient(ep, model, { system, tools }) {
8
+ const client = make(ep);
9
+ const toolDefs = tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.input_schema } }));
10
+
11
+ return {
12
+ async step(messages, events, signal) {
13
+ const stream = await client.chat.completions.create({ model, messages: [{ role: 'system', content: system }, ...messages], tools: toolDefs, stream: true, stream_options: { include_usage: true } }, { signal });
14
+ let text = '';
15
+ const calls = [];
16
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
17
+ let stop = 'end_turn';
18
+ for await (const chunk of stream) {
19
+ if (chunk.usage) { usage.input = chunk.usage.prompt_tokens || 0; usage.output = chunk.usage.completion_tokens || 0; }
20
+ const choice = chunk.choices?.[0];
21
+ if (!choice) continue;
22
+ if (choice.finish_reason === 'length') stop = 'max_tokens';
23
+ const d = choice.delta;
24
+ if (d?.content) { text += d.content; events.text?.(d.content); }
25
+ for (const tc of d?.tool_calls || []) {
26
+ const i = tc.index ?? calls.length;
27
+ calls[i] ||= { id: '', name: '', args: '' };
28
+ if (tc.id) calls[i].id = tc.id;
29
+ if (tc.function?.name) calls[i].name += tc.function.name;
30
+ if (tc.function?.arguments) calls[i].args += tc.function.arguments;
31
+ }
32
+ }
33
+ const parsed = calls.filter(Boolean).map((c, i) => {
34
+ let input = null;
35
+ try { input = JSON.parse(c.args || '{}'); } catch { input = null; }
36
+ return { id: c.id || `call_${Date.now()}_${i}`, name: c.name, input, raw: c.args };
37
+ });
38
+ return { text, calls: parsed, stop, usage, content: null };
39
+ },
40
+ assistant: (r) => ({
41
+ role: 'assistant',
42
+ content: r.text || null,
43
+ ...(r.calls.length && { tool_calls: r.calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: c.raw || JSON.stringify(c.input || {}) } })) }),
44
+ }),
45
+ results: (list) => list.map((x) => ({ role: 'tool', tool_call_id: x.id, content: x.error ? `error: ${x.text}` : x.text })),
46
+ user: (text) => ({ role: 'user', content: text }),
47
+ };
48
+ }
49
+
50
+ export async function openaiModels(ep) {
51
+ const out = [];
52
+ for await (const m of make(ep).models.list()) out.push(m.id);
53
+ return out.sort();
54
+ }
package/src/modes.js ADDED
@@ -0,0 +1,64 @@
1
+ // Modes decide what the agent is allowed to do and how it behaves.
2
+ // Built-ins: build, page, plan. Your own: JSON files in ~/.morphe/modes/ or ./.morphe/modes/.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { HOME_DIR, LOCAL_DIR } from './config.js';
6
+ import { ALL_TOOLS, FILE_TOOLS, READ_TOOLS } from './tools.js';
7
+
8
+ const PAGE = `PAGE mode: the same job Morphe does on the web, here in this folder.
9
+ The user describes a page, app, game or tool in plain language. Build it as ONE self-contained file, index.html, in the project root.
10
+ - If index.html already exists, view it first. Prefer patch for small changes and write for larger ones. Keep what the user did not mention intact.
11
+ - Everything inline: <style> and <script> live in the file. Libraries only from https://cdn.jsdelivr.net or https://unpkg.com, versions pinned.
12
+ - Wrap localStorage in try/catch; no alert, confirm or prompt; no network calls other than CDN assets and public keyless APIs.
13
+ - Make it really work: real state and interactions, no dead buttons, no lorem ipsum when realistic sample content fits.
14
+ - Design with care: good type (Google Fonts are fine), room to breathe, one coherent palette, restrained motion, works at 360px wide, readable contrast.
15
+ - Never paste the file into the reply. When done, say in a sentence or two what was built and remind the user to open index.html in a browser.`;
16
+
17
+ const PLAN = `PLAN mode: read only. You cannot change files or run commands. Explore the project, then answer with a numbered plan the user can approve before anything is changed.`;
18
+
19
+ export const BUILT_IN = {
20
+ build: { about: 'Reads, writes and runs commands in the project. The default.', tools: ALL_TOOLS, system: '' },
21
+ page: { about: 'One sentence in, a working index.html out, exactly like the website. No commands.', tools: FILE_TOOLS, system: PAGE },
22
+ plan: { about: 'Looks around and writes a plan. Changes nothing.', tools: READ_TOOLS, system: PLAN },
23
+ };
24
+
25
+ function fromFile(file) {
26
+ const m = JSON.parse(fs.readFileSync(file, 'utf8'));
27
+ const tools = Array.isArray(m.tools) ? m.tools.filter((t) => ALL_TOOLS.includes(t)) : ALL_TOOLS;
28
+ return { about: m.about || 'custom mode', tools, system: m.system || '', model: m.model || '', file };
29
+ }
30
+
31
+ export function loadModes(cwd = process.cwd()) {
32
+ const modes = { ...BUILT_IN };
33
+ for (const dir of [path.join(HOME_DIR, 'modes'), path.join(cwd, LOCAL_DIR, 'modes')]) {
34
+ if (!fs.existsSync(dir)) continue;
35
+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.json')) modes[f.slice(0, -5)] = fromFile(path.join(dir, f));
36
+ }
37
+ return modes;
38
+ }
39
+
40
+ function projectNotes(cwd) {
41
+ for (const name of ['MORPHE.md', 'AGENTS.md']) {
42
+ const f = path.join(cwd, name);
43
+ if (fs.existsSync(f)) return `\n\n## Notes from the project (${name})\n${fs.readFileSync(f, 'utf8').trim()}`;
44
+ }
45
+ return '';
46
+ }
47
+
48
+ /** The system prompt for a mode in a given folder. */
49
+ export function systemPrompt(mode, cwd = process.cwd()) {
50
+ const shell = process.platform === 'win32' ? 'cmd.exe' : process.env.SHELL || 'sh';
51
+ return `You are Morphe, a coding agent working inside the user's project from their terminal. They say what they want; you make it happen in the files.
52
+
53
+ Where you are:
54
+ - project root: ${cwd}
55
+ - platform: ${process.platform}; the shell tool uses ${shell}
56
+ - today: ${new Date().toISOString().slice(0, 10)}
57
+
58
+ How you work:
59
+ - Look before you leap: list and view the files that matter and follow the conventions already there.
60
+ - Use patch for surgical edits and write for new files or rewrites.
61
+ - When the project has a build or tests, run them after changing code and fix what breaks.
62
+ - Keep replies short: what changed, and anything the user still has to do.
63
+ - If a request is ambiguous and guessing wrong would be expensive, ask one short question first.${mode.system ? `\n\n${mode.system}` : ''}${projectNotes(cwd)}`;
64
+ }
package/src/tools.js ADDED
@@ -0,0 +1,139 @@
1
+ // What the agent can do to a project. Every path is resolved inside the project root; nothing
2
+ // escapes it. Each tool returns a plain string the model reads back.
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.morphe', '.venv', 'venv', '__pycache__', '.cache', 'coverage', 'target']);
8
+ const CAP = 24_000;
9
+ const cap = (s) => (s.length > CAP ? s.slice(0, CAP) + `\n…(${s.length - CAP} more characters)` : s);
10
+
11
+ const prop = (type, description, extra = {}) => ({ type, description, ...extra });
12
+ const def = (name, description, properties, required) => ({ name, description, input_schema: { type: 'object', properties, required, additionalProperties: false } });
13
+
14
+ export const TOOL_DEFS = [
15
+ def('ls', 'List a folder inside the project.', { dir: prop('string', 'Folder relative to the project root. "." is the root.') }, ['dir']),
16
+ def('view', 'Read a text file with line numbers. Use from/count on long files.', { file: prop('string', 'Path relative to the project root.'), from: prop('integer', 'First line (1-based). Default 1.'), count: prop('integer', 'How many lines. Default 1500.') }, ['file']),
17
+ def('glob', 'Find files whose path matches a pattern such as "src/**/*.js".', { pattern: prop('string', 'Glob pattern relative to the project root.') }, ['pattern']),
18
+ def('grep', 'Search file contents with a regular expression. Lines come back as file:line: text.', { regex: prop('string', 'JavaScript regular expression.'), dir: prop('string', 'Folder to search. Default ".".') }, ['regex']),
19
+ def('write', 'Create a file or replace its whole content.', { file: prop('string', 'Path relative to the project root.'), content: prop('string', 'The complete new content.') }, ['file', 'content']),
20
+ def('patch', 'Replace one exact snippet in a file. The snippet must appear exactly once; include a few surrounding lines to make it unique.', { file: prop('string', 'Path relative to the project root.'), find: prop('string', 'Exact text to replace.'), replace: prop('string', 'Replacement text.') }, ['file', 'find', 'replace']),
21
+ def('shell', 'Run a shell command in the project root. Output is returned; it stops after 2 minutes.', { command: prop('string', 'The command line.') }, ['command']),
22
+ ];
23
+
24
+ export const READ_TOOLS = ['ls', 'view', 'glob', 'grep'];
25
+ export const FILE_TOOLS = [...READ_TOOLS, 'write', 'patch'];
26
+ export const ALL_TOOLS = TOOL_DEFS.map((t) => t.name);
27
+
28
+ function inside(root, p) {
29
+ const abs = path.resolve(root, p || '.');
30
+ const rel = path.relative(root, abs);
31
+ if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`"${p}" is outside the project.`);
32
+ return abs;
33
+ }
34
+
35
+ function* files(root, dir = root) {
36
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
37
+ if (SKIP.has(e.name)) continue;
38
+ const full = path.join(dir, e.name);
39
+ if (e.isDirectory()) yield* files(root, full);
40
+ else if (e.isFile()) yield path.relative(root, full).replaceAll(path.sep, '/');
41
+ }
42
+ }
43
+
44
+ function globMatcher(pattern) {
45
+ let re = '^';
46
+ for (let i = 0; i < pattern.length; i++) {
47
+ const c = pattern[i];
48
+ if (c === '*' && pattern[i + 1] === '*') { re += pattern[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += pattern[i + 2] === '/' ? 2 : 1; }
49
+ else if (c === '*') re += '[^/]*';
50
+ else if (c === '?') re += '[^/]';
51
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
52
+ }
53
+ return new RegExp(re + '$');
54
+ }
55
+
56
+ function runShell(command, cwd) {
57
+ return new Promise((resolve) => {
58
+ const child = spawn(command, { cwd, shell: true, env: process.env });
59
+ let out = '';
60
+ child.stdout.on('data', (d) => (out += d));
61
+ child.stderr.on('data', (d) => (out += d));
62
+ const timer = setTimeout(() => { child.kill(); out += '\n[stopped after 120 s]'; }, 120_000);
63
+ child.on('close', (code) => { clearTimeout(timer); resolve(`exit ${code}\n${cap(out.trim())}`); });
64
+ child.on('error', (e) => { clearTimeout(timer); resolve(`could not start: ${e.message}`); });
65
+ });
66
+ }
67
+
68
+ /** One-line description for the terminal. */
69
+ export const describe = (name, input) => `${name} ${String(input?.file ?? input?.dir ?? input?.pattern ?? input?.regex ?? input?.command ?? '').split('\n')[0].slice(0, 90)}`.trim();
70
+
71
+ /**
72
+ * Execute a tool. `ctx` = { root, allowed:Set, approve(kind, what):Promise<boolean> }.
73
+ * Returns { text, error }.
74
+ */
75
+ export async function execute(name, input, ctx) {
76
+ const t = TOOL_DEFS.find((x) => x.name === name);
77
+ if (!t || !ctx.allowed.has(name)) return { text: `The tool "${name}" is not available in this mode.`, error: true };
78
+ for (const k of t.input_schema.required) if (typeof input?.[k] !== 'string') return { text: `"${k}" must be a string.`, error: true };
79
+ try {
80
+ switch (name) {
81
+ case 'ls': {
82
+ const list = fs.readdirSync(inside(ctx.root, input.dir), { withFileTypes: true }).filter((e) => !SKIP.has(e.name));
83
+ return { text: list.map((e) => (e.isDirectory() ? e.name + '/' : e.name)).join('\n') || '(empty folder)' };
84
+ }
85
+ case 'view': {
86
+ const lines = fs.readFileSync(inside(ctx.root, input.file), 'utf8').split('\n');
87
+ const from = Math.max(1, input.from || 1);
88
+ const take = lines.slice(from - 1, from - 1 + (input.count || 1500));
89
+ const tail = from - 1 + take.length < lines.length ? `\n…(${lines.length} lines in total)` : '';
90
+ return { text: cap(take.map((l, i) => `${from + i}: ${l}`).join('\n')) + tail };
91
+ }
92
+ case 'glob': {
93
+ const m = globMatcher(input.pattern);
94
+ const hits = [];
95
+ for (const f of files(ctx.root)) if (m.test(f) && hits.push(f) >= 400) break;
96
+ return { text: hits.join('\n') || 'nothing matched' };
97
+ }
98
+ case 'grep': {
99
+ const re = new RegExp(input.regex);
100
+ const base = inside(ctx.root, input.dir || '.');
101
+ const hits = [];
102
+ scan: for (const f of files(ctx.root, base)) {
103
+ let text;
104
+ try { text = fs.readFileSync(path.join(ctx.root, f), 'utf8'); } catch { continue; }
105
+ if (text.includes('\0')) continue;
106
+ const lines = text.split('\n');
107
+ for (let i = 0; i < lines.length; i++) if (re.test(lines[i]) && hits.push(`${f}:${i + 1}: ${lines[i].trim().slice(0, 180)}`) >= 200) break scan;
108
+ }
109
+ return { text: hits.join('\n') || 'no matches' };
110
+ }
111
+ case 'write': {
112
+ const file = inside(ctx.root, input.file);
113
+ const existed = fs.existsSync(file);
114
+ if (!(await ctx.approve('write', `${existed ? 'replace' : 'create'} ${input.file} (${input.content.split('\n').length} lines)`))) return { text: 'The user did not allow this write.', error: true };
115
+ fs.mkdirSync(path.dirname(file), { recursive: true });
116
+ fs.writeFileSync(file, input.content);
117
+ return { text: `${existed ? 'replaced' : 'created'} ${input.file}` };
118
+ }
119
+ case 'patch': {
120
+ const file = inside(ctx.root, input.file);
121
+ const text = fs.readFileSync(file, 'utf8');
122
+ const n = text.split(input.find).length - 1;
123
+ if (n !== 1) return { text: `the snippet appears ${n} times in ${input.file}; it has to appear exactly once`, error: true };
124
+ const preview = [...input.find.split('\n').slice(0, 6).map((l) => '- ' + l), ...input.replace.split('\n').slice(0, 6).map((l) => '+ ' + l)].join('\n');
125
+ if (!(await ctx.approve('write', `patch ${input.file}\n${preview}`))) return { text: 'The user did not allow this patch.', error: true };
126
+ fs.writeFileSync(file, text.replace(input.find, () => input.replace));
127
+ return { text: `patched ${input.file}` };
128
+ }
129
+ case 'shell': {
130
+ if (!(await ctx.approve('shell', `run: ${input.command}`))) return { text: 'The user did not allow this command.', error: true };
131
+ return { text: await runShell(input.command, ctx.root) };
132
+ }
133
+ }
134
+ } catch (e) {
135
+ return { text: e.message, error: true };
136
+ }
137
+ }
138
+
139
+ export { execFile };
package/src/ui.js ADDED
@@ -0,0 +1,44 @@
1
+ // Terminal colours and a tiny prompt helper. No dependencies.
2
+ import readline from 'node:readline';
3
+
4
+ const colour = process.stdout.isTTY && !process.env.NO_COLOR;
5
+ const wrap = (open) => (text) => (colour ? `\x1b[${open}m${text}\x1b[0m` : String(text));
6
+
7
+ export const orange = wrap('38;5;208');
8
+ export const sky = wrap('38;5;111');
9
+ export const faint = wrap('2');
10
+ export const strong = wrap('1');
11
+ export const ok = wrap('32');
12
+ export const bad = wrap('31');
13
+
14
+ export const LOGO = orange(`
15
+ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗
16
+ ████╗ ████║██╔═══██╗██╔══██╗██╔══██╗██║ ██║██╔════╝
17
+ ██╔████╔██║██║ ██║██████╔╝██████╔╝███████║█████╗
18
+ ██║╚██╔╝██║██║ ██║██╔══██╗██╔═══╝ ██╔══██║██╔══╝
19
+ ██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ██║ ██║███████╗
20
+ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝`) + faint('\n say it. launch it.\n');
21
+
22
+ /** Line-based prompt. `secret` hides what is typed (for keys). */
23
+ export function prompter() {
24
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: !!process.stdin.isTTY });
25
+ let hide = false;
26
+ const out = rl._writeToOutput.bind(rl);
27
+ rl._writeToOutput = (s) => out(hide && !s.includes('\n') ? '•'.repeat(s.length) : s);
28
+ const ask = (q) => new Promise((res) => rl.question(q, res));
29
+ return {
30
+ rl,
31
+ ask: async (q) => (await ask(q)).trim(),
32
+ secret: async (q) => { process.stdout.write(q); hide = true; const v = await ask(''); hide = false; return v.trim(); },
33
+ close: () => rl.close(),
34
+ };
35
+ }
36
+
37
+ /** A one-line spinner that can be replaced by the real output. */
38
+ export function spinner(label) {
39
+ if (!process.stdout.isTTY) return { stop() {} };
40
+ const frames = ['◐', '◓', '◑', '◒'];
41
+ let i = 0;
42
+ const t = setInterval(() => process.stdout.write(`\r ${sky(frames[i++ % 4])} ${label}`), 110);
43
+ return { stop() { clearInterval(t); process.stdout.write('\r' + ' '.repeat(label.length + 6) + '\r'); } };
44
+ }
package/src/version.js ADDED
@@ -0,0 +1,2 @@
1
+ // Mirrors package.json; kept in source so the compiled binary knows itself.
2
+ export const VERSION = '0.2.1';