@denizokcu/haze 0.0.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/haze.js +2 -0
- package/dist/cli/commands/chat.d.ts +6 -0
- package/dist/cli/commands/chat.js +101 -0
- package/dist/cli/commands/commands.d.ts +15 -0
- package/dist/cli/commands/commands.js +46 -0
- package/dist/cli/commands/formatters.d.ts +8 -0
- package/dist/cli/commands/formatters.js +46 -0
- package/dist/cli/commands/skills.d.ts +4 -0
- package/dist/cli/commands/skills.js +35 -0
- package/dist/cli/commands/streaming.d.ts +19 -0
- package/dist/cli/commands/streaming.js +101 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +31 -0
- package/dist/config/contextFiles.d.ts +5 -0
- package/dist/config/contextFiles.js +59 -0
- package/dist/config/inputHistory.d.ts +4 -0
- package/dist/config/inputHistory.js +29 -0
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.js +5 -0
- package/dist/config/settings.d.ts +10 -0
- package/dist/config/settings.js +16 -0
- package/dist/llm/client.d.ts +1 -0
- package/dist/llm/client.js +11 -0
- package/dist/llm/hazeTools.d.ts +82 -0
- package/dist/llm/hazeTools.js +226 -0
- package/dist/llm/initPrompt.d.ts +1 -0
- package/dist/llm/initPrompt.js +19 -0
- package/dist/llm/systemPrompt.d.ts +2 -0
- package/dist/llm/systemPrompt.js +33 -0
- package/dist/skills/SkillLoader.d.ts +2 -0
- package/dist/skills/SkillLoader.js +22 -0
- package/dist/skills/SkillRegistry.d.ts +6 -0
- package/dist/skills/SkillRegistry.js +28 -0
- package/dist/skills/builder/SkillBuilder.d.ts +1 -0
- package/dist/skills/builder/SkillBuilder.js +25 -0
- package/dist/skills/installer/SkillInstaller.d.ts +1 -0
- package/dist/skills/installer/SkillInstaller.js +48 -0
- package/dist/skills/manifestSchema.d.ts +31 -0
- package/dist/skills/manifestSchema.js +23 -0
- package/dist/skills/types.d.ts +60 -0
- package/dist/skills/types.js +1 -0
- package/dist/tools/ToolExecutor.d.ts +3 -0
- package/dist/tools/ToolExecutor.js +15 -0
- package/dist/tools/types.d.ts +9 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/components/ErrorView.d.ts +3 -0
- package/dist/ui/components/ErrorView.js +7 -0
- package/dist/ui/components/Header.d.ts +3 -0
- package/dist/ui/components/Header.js +6 -0
- package/dist/ui/components/MarkdownText.d.ts +3 -0
- package/dist/ui/components/MarkdownText.js +108 -0
- package/dist/ui/components/TextInput.d.ts +9 -0
- package/dist/ui/components/TextInput.js +120 -0
- package/dist/ui/theme.d.ts +11 -0
- package/dist/ui/theme.js +11 -0
- package/dist/utils/fs.d.ts +14 -0
- package/dist/utils/fs.js +36 -0
- package/dist/utils/path.d.ts +3 -0
- package/dist/utils/path.js +16 -0
- package/dist/utils/yaml.d.ts +2 -0
- package/dist/utils/yaml.js +8 -0
- package/examples/skills/files/prompts/file_tasks.md +1 -0
- package/examples/skills/files/skill.yaml +28 -0
- package/examples/skills/files/tools/list_files.ts +21 -0
- package/examples/skills/files/tools/read_file.ts +12 -0
- package/package.json +71 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1 - 2026-05-31
|
|
4
|
+
|
|
5
|
+
Initial public release.
|
|
6
|
+
|
|
7
|
+
- Interactive terminal chat CLI for agentic app-building workflows.
|
|
8
|
+
- OpenRouter-compatible model configuration via `/login`, `/model`, and environment variables.
|
|
9
|
+
- Vercel AI SDK tool calling with multi-step agent execution.
|
|
10
|
+
- Transparent tool call display in the chat transcript.
|
|
11
|
+
- Workspace file tools: list, read, exact edit, line-range replace, and write.
|
|
12
|
+
- `.gitignore`-aware file access with explicit ignored-file overrides when needed.
|
|
13
|
+
- Bash tool for tests, builds, and shell commands.
|
|
14
|
+
- Persistent input history in `~/.haze/history/input-history.json`.
|
|
15
|
+
- Skill management commands for listing, inspecting, validating, installing, and building file-based skills.
|
|
16
|
+
- Debug mode via `haze --debug`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deniz Okcu
|
|
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
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Haze
|
|
2
|
+
|
|
3
|
+
Haze is a pragmatic agentic CLI for building apps from the terminal. It uses the Vercel AI SDK, OpenAI-compatible providers such as OpenRouter, and transparent local tools for reading, editing, writing, and testing files.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @denizokcu/haze
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then start Haze:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
haze
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For local development from this repository:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install
|
|
21
|
+
npm run dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## First-time setup
|
|
25
|
+
|
|
26
|
+
Inside Haze, configure OpenRouter:
|
|
27
|
+
|
|
28
|
+
```txt
|
|
29
|
+
/login
|
|
30
|
+
/model openai/gpt-4o-mini
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`/login` stores settings in `~/.haze/settings.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"provider": "openrouter",
|
|
38
|
+
"apiKey": "...",
|
|
39
|
+
"baseURL": "https://openrouter.ai/api/v1",
|
|
40
|
+
"model": "openai/gpt-4o-mini"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Environment variables override saved settings:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export OPENAI_API_KEY=...
|
|
48
|
+
export OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
|
49
|
+
export HAZE_MODEL=openai/gpt-4o-mini
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
haze
|
|
56
|
+
haze --debug
|
|
57
|
+
haze skills list
|
|
58
|
+
haze skills info <name>
|
|
59
|
+
haze skills validate <dir>
|
|
60
|
+
haze install-skill <githubRepo>
|
|
61
|
+
haze build-skill <description>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Chat commands:
|
|
65
|
+
|
|
66
|
+
```txt
|
|
67
|
+
/help
|
|
68
|
+
/login
|
|
69
|
+
/model <name>
|
|
70
|
+
/model
|
|
71
|
+
/settings
|
|
72
|
+
/init
|
|
73
|
+
/clear
|
|
74
|
+
/exit
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`/init` explores the current workspace using `.gitignore`-aware tools and creates or updates an `AGENTS.md` file with project instructions for future Haze sessions.
|
|
78
|
+
|
|
79
|
+
Input conveniences:
|
|
80
|
+
|
|
81
|
+
- `↑` / `↓` browse persisted input history.
|
|
82
|
+
- `←` / `→` move the cursor.
|
|
83
|
+
- `Esc` clears the input field.
|
|
84
|
+
- `Ctrl+A` / `Ctrl+E` jump to start/end.
|
|
85
|
+
|
|
86
|
+
Input history is stored in `~/.haze/history/input-history.json`.
|
|
87
|
+
|
|
88
|
+
## Agent tools
|
|
89
|
+
|
|
90
|
+
Haze exposes a small toolset to the model:
|
|
91
|
+
|
|
92
|
+
- `listFiles` — structured project discovery.
|
|
93
|
+
- `readFile` — read UTF-8 files with optional line ranges.
|
|
94
|
+
- `editFile` — exact unique text replacements.
|
|
95
|
+
- `replaceLines` — replace a 1-based line range when exact edits are ambiguous.
|
|
96
|
+
- `writeFile` — create or overwrite files.
|
|
97
|
+
- `bash` — run shell commands for tests, builds, and inspection.
|
|
98
|
+
|
|
99
|
+
Tool calls are shown inline in the chat transcript so you can see what Haze is doing.
|
|
100
|
+
|
|
101
|
+
## Context files
|
|
102
|
+
|
|
103
|
+
Haze loads project instructions from context files and includes them in the system prompt:
|
|
104
|
+
|
|
105
|
+
- `~/.haze/AGENTS.md`
|
|
106
|
+
- `~/.haze/CLAUDE.md`
|
|
107
|
+
- `AGENTS.md` files found while walking from the filesystem root to the current workspace
|
|
108
|
+
- `CLAUDE.md` files found while walking from the filesystem root to the current workspace
|
|
109
|
+
|
|
110
|
+
Use `AGENTS.md` for shared project instructions. `CLAUDE.md` is supported for compatibility with existing projects.
|
|
111
|
+
|
|
112
|
+
## Safety model
|
|
113
|
+
|
|
114
|
+
- File tools are restricted to the current workspace.
|
|
115
|
+
- File tools follow `.gitignore` by default.
|
|
116
|
+
- Ignored files can still be accessed when explicitly needed by using the tool's ignored-file override.
|
|
117
|
+
- Haze is prompted to ask before destructive actions.
|
|
118
|
+
- Bash is powerful; review commands shown in the transcript, especially in early releases.
|
|
119
|
+
|
|
120
|
+
## Skills
|
|
121
|
+
|
|
122
|
+
Default skill locations:
|
|
123
|
+
|
|
124
|
+
- `~/.haze/skills/`
|
|
125
|
+
- `./.haze/skills/` local overrides global
|
|
126
|
+
|
|
127
|
+
A skill is a directory containing `skill.yaml`, optional prompts, and TypeScript tool files. Skill tools run in a subprocess via `tsx`.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm install
|
|
133
|
+
npm run typecheck
|
|
134
|
+
npm run build
|
|
135
|
+
npm pack --dry-run
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The npm package intentionally ships only `bin`, `dist`, `README.md`, `LICENSE`, `CHANGELOG.md`, and `examples`.
|
|
139
|
+
|
|
140
|
+
## Release
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm run typecheck
|
|
144
|
+
npm run build
|
|
145
|
+
npm pack --dry-run
|
|
146
|
+
git tag v0.0.1
|
|
147
|
+
git push origin main --tags
|
|
148
|
+
npm publish --access public
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
package/bin/haze.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, render, Text, useApp, useStdout } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { readContextFiles } from '../../config/contextFiles.js';
|
|
6
|
+
import { addInputHistoryItem, readInputHistory } from '../../config/inputHistory.js';
|
|
7
|
+
import { readSettings, updateSettings } from '../../config/settings.js';
|
|
8
|
+
import { Header } from '../../ui/components/Header.js';
|
|
9
|
+
import { TextInput } from '../../ui/components/TextInput.js';
|
|
10
|
+
import { MarkdownText } from '../../ui/components/MarkdownText.js';
|
|
11
|
+
import { theme } from '../../ui/theme.js';
|
|
12
|
+
import { handleSlashCommand } from './commands.js';
|
|
13
|
+
import { runAgentTurn } from './streaming.js';
|
|
14
|
+
function ChatScreen({ debug = false }) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const { stdout } = useStdout();
|
|
17
|
+
const height = stdout.rows ?? process.stdout.rows ?? 24;
|
|
18
|
+
const [messages, setMessages] = useState([
|
|
19
|
+
{ role: 'system', text: 'Welcome to Haze. Use /login for OpenRouter, /model to choose a model, /help for commands.' }
|
|
20
|
+
]);
|
|
21
|
+
const [settings, setSettings] = useState({});
|
|
22
|
+
const conversationRef = useRef([]);
|
|
23
|
+
const lastAssistantTextRef = useRef('');
|
|
24
|
+
const [inputHistory, setInputHistory] = useState([]);
|
|
25
|
+
const [debugLogs, setDebugLogs] = useState([]);
|
|
26
|
+
const [contextFiles, setContextFiles] = useState([]);
|
|
27
|
+
const [mode, setMode] = useState('chat');
|
|
28
|
+
const [busy, setBusy] = useState(false);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
readSettings().then(setSettings).catch(() => undefined);
|
|
31
|
+
readInputHistory().then(setInputHistory).catch(() => undefined);
|
|
32
|
+
readContextFiles().then(setContextFiles).catch(() => undefined);
|
|
33
|
+
}, []);
|
|
34
|
+
function persistInputHistory(value) {
|
|
35
|
+
addInputHistoryItem(value).then(setInputHistory).catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
function debugLog(line) {
|
|
38
|
+
if (!debug)
|
|
39
|
+
return;
|
|
40
|
+
setDebugLogs(current => [...current.slice(-7), line]);
|
|
41
|
+
}
|
|
42
|
+
function clearConversation() {
|
|
43
|
+
conversationRef.current = [];
|
|
44
|
+
lastAssistantTextRef.current = '';
|
|
45
|
+
setMessages([{ role: 'system', text: 'Cleared. The void is productive.' }]);
|
|
46
|
+
}
|
|
47
|
+
async function submit(value) {
|
|
48
|
+
if (busy)
|
|
49
|
+
return;
|
|
50
|
+
if (mode === 'apiKey') {
|
|
51
|
+
const next = await updateSettings({ provider: 'openrouter', apiKey: value, baseURL: 'https://openrouter.ai/api/v1' });
|
|
52
|
+
setSettings(next);
|
|
53
|
+
setMode('chat');
|
|
54
|
+
setMessages(m => [...m, { role: 'system', text: 'OpenRouter login saved to ~/.haze/settings.json. Security theatre completed.' }]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (mode === 'model') {
|
|
58
|
+
const next = await updateSettings({ model: value });
|
|
59
|
+
setSettings(next);
|
|
60
|
+
setMode('chat');
|
|
61
|
+
setMessages(m => [...m, { role: 'system', text: `Model set to ${value}.` }]);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const ctx = {
|
|
65
|
+
settings,
|
|
66
|
+
contextFiles,
|
|
67
|
+
setMode,
|
|
68
|
+
addSystemMessage: text => setMessages(m => [...m, { role: 'system', text }]),
|
|
69
|
+
clearConversation,
|
|
70
|
+
runAgentTurn: (prompt, displayValue) => doAgentTurn(prompt, displayValue),
|
|
71
|
+
refreshContextFiles: async () => { const files = await readContextFiles().catch(() => contextFiles); setContextFiles(files); return files; },
|
|
72
|
+
updateSettings: patch => { const next = { ...settings, ...patch }; setSettings(next); return Promise.resolve(next); },
|
|
73
|
+
};
|
|
74
|
+
const result = await handleSlashCommand(value, ctx);
|
|
75
|
+
if (result === 'exit')
|
|
76
|
+
return exit();
|
|
77
|
+
if (result === 'handled')
|
|
78
|
+
return;
|
|
79
|
+
await doAgentTurn(value);
|
|
80
|
+
}
|
|
81
|
+
async function doAgentTurn(value, displayValue) {
|
|
82
|
+
setDebugLogs([]);
|
|
83
|
+
await runAgentTurn(value, displayValue, contextFiles, {
|
|
84
|
+
addMessage: msg => setMessages(m => [...m, msg]),
|
|
85
|
+
updateMessage: (id, update) => setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg)),
|
|
86
|
+
setConversation: msgs => { conversationRef.current = msgs; },
|
|
87
|
+
setBusy,
|
|
88
|
+
debugLog,
|
|
89
|
+
getConversation: () => conversationRef.current,
|
|
90
|
+
getLastAssistantText: () => lastAssistantTextRef.current,
|
|
91
|
+
setLastAssistantText: text => { lastAssistantTextRef.current = text; },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const visible = messages;
|
|
95
|
+
const placeholder = mode === 'apiKey' ? 'OpenRouter API key...' : mode === 'model' ? 'openai/gpt-4o-mini' : busy ? 'Thinking, allegedly...' : 'Ask Haze to help build your app...';
|
|
96
|
+
return _jsxs(Box, { flexDirection: "column", minHeight: height, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Header, { subtitle: "AI agent CLI for building apps" }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visible.map((message, index) => _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: message.role === 'user' ? theme.purple : message.role === 'assistant' ? theme.success : message.role === 'tool' ? theme.muted : theme.muted, bold: true, children: message.role === 'user' ? 'You' : message.role === 'assistant' ? 'Haze' : message.role === 'tool' ? 'Tool' : 'Info' }), message.role === 'assistant' && !message.streaming ? _jsx(MarkdownText, { content: message.text }) : _jsx(Text, { color: message.role === 'tool' ? theme.muted : undefined, children: message.streaming && message.role === 'tool' ? _jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), " ", message.text] }) : message.text })] }, index)) }), debug && debugLogs.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Debug" }), debugLogs.map((line, index) => _jsxs(Text, { color: theme.muted, children: ["\u2022 ", line] }, index))] }), busy && _jsx(Box, { flexShrink: 0, marginBottom: 1, children: _jsxs(Text, { color: theme.muted, children: [_jsx(Spinner, { type: "dots" }), " Haze is thinking..."] }) }), _jsx(Box, { borderStyle: "round", borderColor: theme.deepPurple, paddingX: 1, height: 3, flexShrink: 0, children: _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(TextInput, { placeholder: placeholder, disabled: busy, mask: mode === 'apiKey', historyItems: inputHistory, recordHistory: mode === 'chat', onHistoryAdd: persistInputHistory, onSubmit: submit }) }) })] });
|
|
97
|
+
}
|
|
98
|
+
export async function chatCommand(options = {}) {
|
|
99
|
+
const app = render(_jsx(ChatScreen, { debug: options.debug }));
|
|
100
|
+
await app.waitUntilExit();
|
|
101
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ContextFile } from '../../config/contextFiles.js';
|
|
2
|
+
import type { HazeSettings } from '../../config/settings.js';
|
|
3
|
+
import type { Mode } from './chat.js';
|
|
4
|
+
export type CommandContext = {
|
|
5
|
+
settings: HazeSettings;
|
|
6
|
+
contextFiles: ContextFile[];
|
|
7
|
+
setMode: (mode: Mode) => void;
|
|
8
|
+
addSystemMessage: (text: string) => void;
|
|
9
|
+
clearConversation: () => void;
|
|
10
|
+
runAgentTurn: (prompt: string, displayValue?: string) => Promise<void>;
|
|
11
|
+
refreshContextFiles: () => Promise<ContextFile[]>;
|
|
12
|
+
updateSettings: (patch: Partial<HazeSettings>) => Promise<HazeSettings>;
|
|
13
|
+
};
|
|
14
|
+
export type CommandResult = 'handled' | 'unhandled' | 'exit';
|
|
15
|
+
export declare function handleSlashCommand(value: string, ctx: CommandContext): Promise<CommandResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { buildInitPrompt } from '../../llm/initPrompt.js';
|
|
2
|
+
import { updateSettings } from '../../config/settings.js';
|
|
3
|
+
export async function handleSlashCommand(value, ctx) {
|
|
4
|
+
if (value === '/exit' || value === '/quit')
|
|
5
|
+
return 'exit';
|
|
6
|
+
if (value === '/help') {
|
|
7
|
+
ctx.addSystemMessage('Commands: /login, /model <name>, /model, /settings, /init, /clear, /exit');
|
|
8
|
+
return 'handled';
|
|
9
|
+
}
|
|
10
|
+
if (value === '/clear') {
|
|
11
|
+
ctx.clearConversation();
|
|
12
|
+
ctx.addSystemMessage('Cleared. The void is productive.');
|
|
13
|
+
return 'handled';
|
|
14
|
+
}
|
|
15
|
+
if (value === '/settings') {
|
|
16
|
+
ctx.addSystemMessage(`Provider: ${ctx.settings.provider ?? 'not configured'} | Model: ${ctx.settings.model ?? 'not set'} | API key: ${ctx.settings.apiKey ? 'saved' : 'missing'} | Context files: ${ctx.contextFiles.length ? ctx.contextFiles.map(file => file.path).join(', ') : 'none'}`);
|
|
17
|
+
return 'handled';
|
|
18
|
+
}
|
|
19
|
+
if (value === '/login') {
|
|
20
|
+
ctx.setMode('apiKey');
|
|
21
|
+
ctx.addSystemMessage('Paste your OpenRouter API key. It will be stored in ~/.haze/settings.json.');
|
|
22
|
+
return 'handled';
|
|
23
|
+
}
|
|
24
|
+
if (value === '/model') {
|
|
25
|
+
ctx.setMode('model');
|
|
26
|
+
ctx.addSystemMessage('Enter an OpenRouter model name, e.g. openai/gpt-4o-mini or anthropic/claude-3.5-sonnet.');
|
|
27
|
+
return 'handled';
|
|
28
|
+
}
|
|
29
|
+
if (value.startsWith('/model ')) {
|
|
30
|
+
const modelName = value.slice('/model '.length).trim();
|
|
31
|
+
const next = await updateSettings({ model: modelName });
|
|
32
|
+
ctx.updateSettings(next);
|
|
33
|
+
ctx.addSystemMessage(`Model set to ${modelName}.`);
|
|
34
|
+
return 'handled';
|
|
35
|
+
}
|
|
36
|
+
if (value === '/init') {
|
|
37
|
+
await ctx.runAgentTurn(buildInitPrompt(), '/init');
|
|
38
|
+
await ctx.refreshContextFiles();
|
|
39
|
+
return 'handled';
|
|
40
|
+
}
|
|
41
|
+
if (value.startsWith('/')) {
|
|
42
|
+
ctx.addSystemMessage(`Unknown command: ${value}. Bold start.`);
|
|
43
|
+
return 'handled';
|
|
44
|
+
}
|
|
45
|
+
return 'unhandled';
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function compact(value: unknown, maxLength?: number): string;
|
|
2
|
+
export declare function toolCallSummary(toolName: string, input: unknown): string;
|
|
3
|
+
export declare function toolResultSummary(event: {
|
|
4
|
+
success: boolean;
|
|
5
|
+
output?: unknown;
|
|
6
|
+
error?: unknown;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function formatSeconds(milliseconds: number): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function compact(value, maxLength = 180) {
|
|
2
|
+
let text;
|
|
3
|
+
if (value instanceof Error) {
|
|
4
|
+
text = value.message;
|
|
5
|
+
}
|
|
6
|
+
else if (typeof value === 'string') {
|
|
7
|
+
text = value;
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
text = JSON.stringify(value, (_key, nestedValue) => nestedValue instanceof Error ? nestedValue.message : nestedValue);
|
|
11
|
+
}
|
|
12
|
+
if (!text || text === '{}')
|
|
13
|
+
return String(value);
|
|
14
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
15
|
+
}
|
|
16
|
+
export function toolCallSummary(toolName, input) {
|
|
17
|
+
const data = input;
|
|
18
|
+
if (toolName === 'bash' && typeof data?.command === 'string') {
|
|
19
|
+
const timeout = typeof data.timeoutSeconds === 'number' ? ` (timeout ${data.timeoutSeconds}s)` : '';
|
|
20
|
+
return `$ ${data.command}${timeout}`;
|
|
21
|
+
}
|
|
22
|
+
if (toolName === 'listFiles' && typeof data?.path === 'string')
|
|
23
|
+
return `listFiles ${data.path}`;
|
|
24
|
+
if ((toolName === 'readFile' || toolName === 'writeFile') && typeof data?.path === 'string')
|
|
25
|
+
return `${toolName} ${data.path}`;
|
|
26
|
+
if (toolName === 'editFile' && typeof data?.path === 'string') {
|
|
27
|
+
const edits = Array.isArray(data.edits) ? ` (${data.edits.length} edit${data.edits.length === 1 ? '' : 's'})` : '';
|
|
28
|
+
return `${toolName} ${data.path}${edits}`;
|
|
29
|
+
}
|
|
30
|
+
if (toolName === 'replaceLines' && typeof data?.path === 'string')
|
|
31
|
+
return `replaceLines ${data.path}:${data.startLine}-${data.endLine}`;
|
|
32
|
+
return `${toolName} ${compact(input)}`;
|
|
33
|
+
}
|
|
34
|
+
export function toolResultSummary(event) {
|
|
35
|
+
if (!event.success)
|
|
36
|
+
return `failed: ${compact(event.error)}`;
|
|
37
|
+
const output = event.output;
|
|
38
|
+
if (typeof output?.code === 'number')
|
|
39
|
+
return `exited with code ${output.code}`;
|
|
40
|
+
if (typeof output?.ok === 'boolean')
|
|
41
|
+
return output.ok ? 'completed' : `failed: ${compact(output)}`;
|
|
42
|
+
return 'completed';
|
|
43
|
+
}
|
|
44
|
+
export function formatSeconds(milliseconds) {
|
|
45
|
+
return `${(milliseconds / 1000).toFixed(1)}s`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { render, Box, Text } from 'ink';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { confirm } from '@inquirer/prompts';
|
|
6
|
+
import { loadSkillRegistry } from '../../skills/SkillRegistry.js';
|
|
7
|
+
import { Header } from '../../ui/components/Header.js';
|
|
8
|
+
import { theme } from '../../ui/theme.js';
|
|
9
|
+
export async function listSkills() {
|
|
10
|
+
const registry = await loadSkillRegistry();
|
|
11
|
+
render(_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { subtitle: "Installed skills" }), [...registry.skills.values()].map(s => _jsxs(Text, { children: [_jsx(Text, { color: theme.purple, children: s.manifest.name }), " ", s.manifest.version, " \u2014 ", s.manifest.description, " (", s.source, ")"] }, s.manifest.name))] }));
|
|
12
|
+
}
|
|
13
|
+
export async function infoSkill(name) {
|
|
14
|
+
const registry = await loadSkillRegistry();
|
|
15
|
+
const skill = registry.skills.get(name);
|
|
16
|
+
if (!skill)
|
|
17
|
+
throw new Error(`No skill named ${name}`);
|
|
18
|
+
render(_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { subtitle: `Skill: ${name}` }), _jsx(Text, { children: skill.manifest.description }), _jsx(Text, { color: theme.violet, children: "Tools" }), skill.tools.map(t => _jsxs(Text, { children: [" ", t.id, ": ", t.description] }, t.id)), _jsx(Text, { color: theme.violet, children: "Path" }), _jsx(Text, { children: skill.dir })] }));
|
|
19
|
+
}
|
|
20
|
+
export async function removeSkill(name) {
|
|
21
|
+
const registry = await loadSkillRegistry();
|
|
22
|
+
const skill = registry.skills.get(name);
|
|
23
|
+
if (!skill)
|
|
24
|
+
throw new Error(`No skill named ${name}`);
|
|
25
|
+
const ok = await confirm({ message: `Remove ${name} from ${skill.dir}?`, default: false });
|
|
26
|
+
if (!ok)
|
|
27
|
+
return;
|
|
28
|
+
await fs.remove(skill.dir);
|
|
29
|
+
console.log(`Removed ${name}. A rare case of subtraction as progress.`);
|
|
30
|
+
}
|
|
31
|
+
export async function validateSkill(dir) {
|
|
32
|
+
const { loadSkill } = await import('../../skills/SkillLoader.js');
|
|
33
|
+
const skill = await loadSkill(path.resolve(dir), 'local');
|
|
34
|
+
console.log(skill ? `Valid: ${skill.manifest.name}` : 'No skill.yaml found');
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ModelMessage } from 'ai';
|
|
2
|
+
import type { ContextFile } from '../../config/contextFiles.js';
|
|
3
|
+
export type Message = {
|
|
4
|
+
id?: string;
|
|
5
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
6
|
+
text: string;
|
|
7
|
+
streaming?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export interface StreamCallbacks {
|
|
10
|
+
addMessage: (msg: Message) => void;
|
|
11
|
+
updateMessage: (id: string, update: Partial<Message>) => void;
|
|
12
|
+
setConversation: (messages: ModelMessage[]) => void;
|
|
13
|
+
setBusy: (busy: boolean) => void;
|
|
14
|
+
debugLog: (line: string) => void;
|
|
15
|
+
getConversation: () => ModelMessage[];
|
|
16
|
+
getLastAssistantText: () => string;
|
|
17
|
+
setLastAssistantText: (text: string) => void;
|
|
18
|
+
}
|
|
19
|
+
export declare function runAgentTurn(value: string, displayValue: string | undefined, contextFiles: ContextFile[], callbacks: StreamCallbacks): Promise<void>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { stepCountIs, streamText } from 'ai';
|
|
2
|
+
import { model } from '../../llm/client.js';
|
|
3
|
+
import { hazeTools } from '../../llm/hazeTools.js';
|
|
4
|
+
import { buildSystemPrompt } from '../../llm/systemPrompt.js';
|
|
5
|
+
import { compact, toolCallSummary, toolResultSummary, formatSeconds } from './formatters.js';
|
|
6
|
+
export async function runAgentTurn(value, displayValue, contextFiles, callbacks) {
|
|
7
|
+
const displayVal = displayValue ?? value;
|
|
8
|
+
const userMessage = { role: 'user', text: displayVal };
|
|
9
|
+
callbacks.setBusy(true);
|
|
10
|
+
callbacks.addMessage(userMessage);
|
|
11
|
+
try {
|
|
12
|
+
const m = await model();
|
|
13
|
+
if (!m) {
|
|
14
|
+
callbacks.addMessage({ role: 'assistant', text: 'No model configured. Run /login, then /model <model-name>. Haze cannot hallucinate without credentials. Progress.' });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const lastAssistantText = callbacks.getLastAssistantText();
|
|
18
|
+
const refersToPrevious = /\b(this|that|previous|above|it)\b/i.test(value) && lastAssistantText.trim().length > 0;
|
|
19
|
+
const userContent = refersToPrevious
|
|
20
|
+
? `${value}\n\nReferenced previous Haze response to preserve exactly:\n${lastAssistantText}`
|
|
21
|
+
: value;
|
|
22
|
+
const requestMessages = [...callbacks.getConversation(), { role: 'user', content: userContent }];
|
|
23
|
+
callbacks.setConversation(requestMessages);
|
|
24
|
+
const assistantId = `assistant-${Date.now()}`;
|
|
25
|
+
let assistantStarted = false;
|
|
26
|
+
let assistantText = '';
|
|
27
|
+
let editFileFailed = false;
|
|
28
|
+
let mutatingToolSucceeded = false;
|
|
29
|
+
const toolSummaries = [];
|
|
30
|
+
callbacks.debugLog(`request started with ${requestMessages.length} conversation messages${refersToPrevious ? ' and previous-response reference' : ''}`);
|
|
31
|
+
const result = streamText({
|
|
32
|
+
model: m,
|
|
33
|
+
system: buildSystemPrompt(contextFiles),
|
|
34
|
+
messages: requestMessages,
|
|
35
|
+
tools: hazeTools,
|
|
36
|
+
stopWhen: stepCountIs(15),
|
|
37
|
+
prepareStep() {
|
|
38
|
+
if (mutatingToolSucceeded)
|
|
39
|
+
return { toolChoice: 'none' };
|
|
40
|
+
if (editFileFailed)
|
|
41
|
+
return { activeTools: ['listFiles', 'readFile', 'replaceLines', 'writeFile', 'bash'] };
|
|
42
|
+
return undefined;
|
|
43
|
+
},
|
|
44
|
+
onStepFinish({ stepNumber, text, toolCalls, toolResults, finishReason }) {
|
|
45
|
+
callbacks.debugLog(`step ${stepNumber} finished: ${finishReason}; text=${text.length}; toolCalls=${toolCalls.length}; toolResults=${toolResults.length}`);
|
|
46
|
+
},
|
|
47
|
+
onFinish({ response }) {
|
|
48
|
+
const nextConversation = [...requestMessages, ...response.messages];
|
|
49
|
+
callbacks.setConversation(nextConversation);
|
|
50
|
+
callbacks.debugLog(`conversation updated to ${nextConversation.length} messages`);
|
|
51
|
+
},
|
|
52
|
+
experimental_onToolCallStart({ toolCall }) {
|
|
53
|
+
const text = toolCallSummary(toolCall.toolName, toolCall.input);
|
|
54
|
+
callbacks.addMessage({ id: `tool-${toolCall.toolCallId}`, role: 'tool', text, streaming: true });
|
|
55
|
+
callbacks.debugLog(`tool start: ${toolCall.toolName} ${compact(toolCall.input)}`);
|
|
56
|
+
},
|
|
57
|
+
experimental_onToolCallFinish(event) {
|
|
58
|
+
const summary = toolResultSummary(event);
|
|
59
|
+
const text = `${toolCallSummary(event.toolCall.toolName, event.toolCall.input)}\n${event.success ? '✓' : '✗'} ${summary} in ${formatSeconds(event.durationMs)}`;
|
|
60
|
+
if (!event.success && event.toolCall.toolName === 'editFile')
|
|
61
|
+
editFileFailed = true;
|
|
62
|
+
if (event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName))
|
|
63
|
+
mutatingToolSucceeded = true;
|
|
64
|
+
toolSummaries.push(`${event.toolCall.toolName}: ${summary}`);
|
|
65
|
+
callbacks.updateMessage(`tool-${event.toolCall.toolCallId}`, { text, streaming: false });
|
|
66
|
+
callbacks.debugLog(event.success
|
|
67
|
+
? `tool done: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.output)}`
|
|
68
|
+
: `tool error: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.error)}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
for await (const delta of result.textStream) {
|
|
72
|
+
assistantText += delta;
|
|
73
|
+
if (!assistantStarted) {
|
|
74
|
+
assistantStarted = true;
|
|
75
|
+
callbacks.addMessage({ id: assistantId, role: 'assistant', text: delta, streaming: true });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
callbacks.updateMessage(assistantId, { text: assistantText });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
callbacks.debugLog(`response stream finished; session has ${callbacks.getConversation().length} model messages`);
|
|
82
|
+
if (assistantStarted) {
|
|
83
|
+
callbacks.setLastAssistantText(assistantText.trim());
|
|
84
|
+
callbacks.updateMessage(assistantId, { streaming: false });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const fallback = toolSummaries.length > 0
|
|
88
|
+
? `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.`
|
|
89
|
+
: 'Finished without a text response.';
|
|
90
|
+
callbacks.addMessage({ id: assistantId, role: 'assistant', text: fallback, streaming: false });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
95
|
+
callbacks.debugLog(`error: ${text}`);
|
|
96
|
+
callbacks.addMessage({ role: 'assistant', text: `Model call failed: ${text}` });
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
callbacks.setBusy(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import { listSkills, infoSkill, removeSkill, validateSkill } from './commands/skills.js';
|
|
7
|
+
import { buildSkill } from '../skills/builder/SkillBuilder.js';
|
|
8
|
+
import { installSkill } from '../skills/installer/SkillInstaller.js';
|
|
9
|
+
import { chatCommand } from './commands/chat.js';
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('haze')
|
|
15
|
+
.description('A pragmatic, intentionally limited agentic CLI.')
|
|
16
|
+
.version(pkg.version)
|
|
17
|
+
.option('--debug', 'show simple model/tool debug logs in the chat UI');
|
|
18
|
+
program.action(async () => {
|
|
19
|
+
await chatCommand({ debug: Boolean(program.opts().debug) });
|
|
20
|
+
});
|
|
21
|
+
program.command('build-skill <description...>').description('Deliberately design and create a new file-based skill').action(async (d) => buildSkill(d.join(' ')));
|
|
22
|
+
program.command('install-skill <githubRepo>').description('Install a skill from GitHub with mandatory approval').action(installSkill);
|
|
23
|
+
const skills = program.command('skills').description('Manage skills');
|
|
24
|
+
skills.command('list').description('List installed skills').action(listSkills);
|
|
25
|
+
skills.command('info <name>').description('Show skill details').action(infoSkill);
|
|
26
|
+
skills.command('remove <name>').description('Remove an installed skill').action(removeSkill);
|
|
27
|
+
skills.command('validate <dir>').description('Validate a skill directory').action(validateSkill);
|
|
28
|
+
program.parseAsync().catch(error => {
|
|
29
|
+
console.error(error instanceof Error ? error.message : error);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|