@tianmucreations/jeeves 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/bin/jeeves +2 -0
  4. package/dist/agent/context.js +50 -0
  5. package/dist/agent/errors.js +41 -0
  6. package/dist/agent/loop.js +84 -0
  7. package/dist/agent/permissions.js +27 -0
  8. package/dist/app.js +68 -0
  9. package/dist/commands/clear.js +9 -0
  10. package/dist/commands/help.js +17 -0
  11. package/dist/commands/keys.js +15 -0
  12. package/dist/commands/model.js +4 -0
  13. package/dist/commands/verbose.js +8 -0
  14. package/dist/components/AlternateScreen.js +74 -0
  15. package/dist/components/Footer.js +114 -0
  16. package/dist/components/Header.js +6 -0
  17. package/dist/components/HelpView.js +14 -0
  18. package/dist/components/Input.js +76 -0
  19. package/dist/components/KeysManager.js +281 -0
  20. package/dist/components/ModelPicker.js +457 -0
  21. package/dist/components/ProjectPicker.js +334 -0
  22. package/dist/components/TrafficLight.js +116 -0
  23. package/dist/components/Transcript.js +23 -0
  24. package/dist/components/UsageBar.js +35 -0
  25. package/dist/components/transcript-layout.js +103 -0
  26. package/dist/index.js +53 -0
  27. package/dist/ink/AlternateScreen.js +106 -0
  28. package/dist/keys/store.js +58 -0
  29. package/dist/models/filter.js +4 -0
  30. package/dist/models/registry.js +112 -0
  31. package/dist/platform/config.js +60 -0
  32. package/dist/platform/paths.js +60 -0
  33. package/dist/platform/shell.js +9 -0
  34. package/dist/providers/index.js +165 -0
  35. package/dist/providers/ollama.js +103 -0
  36. package/dist/providers/openrouter.js +109 -0
  37. package/dist/providers/types.js +1 -0
  38. package/dist/providers/zai.js +104 -0
  39. package/dist/state/session.js +315 -0
  40. package/dist/tools/index.js +106 -0
  41. package/dist/tools/listDir.js +55 -0
  42. package/dist/tools/readFile.js +15 -0
  43. package/dist/tools/runBash.js +22 -0
  44. package/dist/tools/writeFile.js +15 -0
  45. package/package.json +62 -0
@@ -0,0 +1,106 @@
1
+ import { tool } from 'ai';
2
+ import { session } from '../state/session.js';
3
+ import { requestApproval } from '../agent/permissions.js';
4
+ import { readFileSchema, runReadFile } from './readFile.js';
5
+ import { writeFileSchema, runWriteFile } from './writeFile.js';
6
+ import { listDirSchema, runListDir } from './listDir.js';
7
+ import { runBashSchema, runRunBash } from './runBash.js';
8
+ // Assumption: every tool result is capped to keep huge outputs from flooding the conversation.
9
+ const MAX_RESULT_CHARS = 150_000;
10
+ function truncate(text) {
11
+ return text.length > MAX_RESULT_CHARS ? text.slice(0, MAX_RESULT_CHARS) + '\n[output truncated]' : text;
12
+ }
13
+ function describeError(error) {
14
+ if (error instanceof Error)
15
+ return error.message;
16
+ return String(error);
17
+ }
18
+ // Plain-English one-line failure text for the transcript; the model still receives
19
+ // the full technical message so it can react.
20
+ export function plainToolFailure(error) {
21
+ const raw = describeError(error);
22
+ const text = raw.toLowerCase();
23
+ if (text.includes('enoent'))
24
+ return 'file or folder not found';
25
+ if (text.includes('eacces') || text.includes('eperm'))
26
+ return 'permission denied';
27
+ if (text.includes('timed out') || text.includes('etimedout') || text.includes('stopped after'))
28
+ return 'took too long';
29
+ if (text.includes('binary file'))
30
+ return 'not a text file';
31
+ const firstLine = raw.split('\n')[0].trim();
32
+ return firstLine.length > 0 ? firstLine : 'failed';
33
+ }
34
+ function clip(text, max) {
35
+ return text.length > max ? text.slice(0, max - 1) + '…' : text;
36
+ }
37
+ function defineTool(config) {
38
+ return tool({
39
+ description: config.description,
40
+ inputSchema: config.schema,
41
+ execute: async (rawInput) => {
42
+ // The SDK validates before execute; parsing again keeps this layer strictly typed.
43
+ const input = config.schema.parse(rawInput);
44
+ const summary = config.summarize(input);
45
+ const lineId = session.addToolLine(config.name, summary, config.permission ? 'awaiting' : 'running');
46
+ if (config.permission) {
47
+ const approved = await requestApproval();
48
+ if (!approved) {
49
+ session.updateToolLine(lineId, { state: 'declined' });
50
+ throw new Error(`Permission denied by the user - ${config.name} ${summary} was not executed.`);
51
+ }
52
+ session.updateToolLine(lineId, { state: 'running' });
53
+ }
54
+ try {
55
+ const result = truncate(await config.run(input));
56
+ session.updateToolLine(lineId, { state: 'done', label: config.label(input, result) });
57
+ return result;
58
+ }
59
+ catch (error) {
60
+ session.updateToolLine(lineId, { state: 'failed', label: plainToolFailure(error) });
61
+ throw error;
62
+ }
63
+ },
64
+ });
65
+ }
66
+ export const TOOLS = {
67
+ readFile: defineTool({
68
+ name: 'readFile',
69
+ description: 'Read the contents of a text file at the given path.',
70
+ schema: readFileSchema,
71
+ permission: false,
72
+ summarize: (input) => input.path,
73
+ label: (input) => `Read ${input.path}`,
74
+ run: runReadFile,
75
+ }),
76
+ listDir: defineTool({
77
+ name: 'listDir',
78
+ description: 'List the files and folders in a directory, ignoring .gitignore rules. Set recursive to true to include subfolders.',
79
+ schema: listDirSchema,
80
+ permission: false,
81
+ summarize: (input) => input.path,
82
+ label: (input, result) => `Listed ${input.path} (${result.split('\n').length} items)`,
83
+ run: runListDir,
84
+ }),
85
+ writeFile: defineTool({
86
+ name: 'writeFile',
87
+ description: 'Write text content to a file, creating the file if it does not exist.',
88
+ schema: writeFileSchema,
89
+ permission: true,
90
+ summarize: (input) => `${input.path} (${input.content.length} characters)`,
91
+ label: () => 'Wrote 1 file',
92
+ run: runWriteFile,
93
+ }),
94
+ runBash: defineTool({
95
+ name: 'runBash',
96
+ description: 'Run a shell command and return its output.',
97
+ schema: runBashSchema,
98
+ permission: true,
99
+ summarize: (input) => clip(input.command, 60),
100
+ label: (input) => `Ran ${clip(input.command, 60)}`,
101
+ run: runRunBash,
102
+ }),
103
+ };
104
+ export function getTools() {
105
+ return TOOLS;
106
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { z } from 'zod';
5
+ import { resolveFromCwd } from '../platform/paths.js';
6
+ export const listDirSchema = z.object({
7
+ path: z.string().describe('Directory to list'),
8
+ recursive: z.boolean().optional().describe('Include all subfolders'),
9
+ });
10
+ // Assumption: only the top-level .gitignore of the listed folder is honoured, and negation
11
+ // rules (!) are not supported - typical project files use plain ignore rules only.
12
+ // Glob patterns use forward slashes on every platform by design (fast-glob normalises them),
13
+ // so these are not filesystem paths and never need path.sep.
14
+ function ignorePatterns(dir) {
15
+ const patterns = ['**/.git', '**/.git/**', '**/node_modules', '**/node_modules/**'];
16
+ const gitignorePath = path.join(dir, '.gitignore');
17
+ if (!existsSync(gitignorePath))
18
+ return patterns;
19
+ const lines = readFileSync(gitignorePath, 'utf8').split('\n');
20
+ for (const raw of lines) {
21
+ const line = raw.trim();
22
+ if (!line || line.startsWith('#') || line.startsWith('!'))
23
+ continue;
24
+ const anchored = line.startsWith('/');
25
+ const cleaned = line.replace(/^\//, '').replace(/\/+$/, '');
26
+ if (!cleaned)
27
+ continue;
28
+ if (anchored) {
29
+ patterns.push(cleaned, `${cleaned}/**`);
30
+ }
31
+ else {
32
+ patterns.push(`**/${cleaned}`, `**/${cleaned}/**`);
33
+ }
34
+ }
35
+ return patterns;
36
+ }
37
+ export async function runListDir(input) {
38
+ const dir = resolveFromCwd(input.path);
39
+ if (!existsSync(dir)) {
40
+ throw new Error('That folder does not exist.');
41
+ }
42
+ const entries = await fg(input.recursive ? ['**/*'] : ['*'], {
43
+ cwd: dir,
44
+ onlyFiles: false,
45
+ dot: false,
46
+ deep: input.recursive ? 8 : 1,
47
+ ignore: ignorePatterns(dir),
48
+ });
49
+ if (entries.length === 0)
50
+ return '(empty folder)';
51
+ const sorted = entries.sort();
52
+ const cap = 2000;
53
+ const shown = sorted.slice(0, cap).join('\n');
54
+ return sorted.length > cap ? `${shown}\n(and ${sorted.length - cap} more)` : shown;
55
+ }
@@ -0,0 +1,15 @@
1
+ import { readFile as fsReadFile } from 'node:fs/promises';
2
+ import { z } from 'zod';
3
+ import { resolveFromCwd } from '../platform/paths.js';
4
+ export const readFileSchema = z.object({
5
+ path: z.string().describe('Path of the file to read, relative to the current folder or absolute'),
6
+ });
7
+ export async function runReadFile(input) {
8
+ const resolved = resolveFromCwd(input.path);
9
+ const contents = await fsReadFile(resolved, 'utf8');
10
+ // Guard against feeding binary files into the conversation as gibberish.
11
+ if (contents.slice(0, 1000).includes('\u0000')) {
12
+ throw new Error('This looks like a binary file; it cannot be read as text.');
13
+ }
14
+ return contents;
15
+ }
@@ -0,0 +1,22 @@
1
+ import { execa } from 'execa';
2
+ import { z } from 'zod';
3
+ import { getShell } from '../platform/shell.js';
4
+ export const runBashSchema = z.object({
5
+ command: z.string().describe('The shell command to run'),
6
+ });
7
+ export async function runRunBash(input) {
8
+ const shell = getShell();
9
+ // Assumption: a two-minute cap stops a runaway command from hanging the session forever.
10
+ const result = await execa(shell.program, [shell.flag, input.command], {
11
+ reject: false,
12
+ timeout: 120_000,
13
+ });
14
+ const parts = [`$ ${input.command}`, `exit code: ${result.exitCode ?? 'unknown'}`];
15
+ if (result.timedOut === true)
16
+ parts.push('The command was stopped after 2 minutes.');
17
+ if (result.stdout)
18
+ parts.push(`stdout:\n${result.stdout}`);
19
+ if (result.stderr)
20
+ parts.push(`stderr:\n${result.stderr}`);
21
+ return parts.join('\n\n');
22
+ }
@@ -0,0 +1,15 @@
1
+ import { mkdir, writeFile as fsWriteFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { resolveFromCwd } from '../platform/paths.js';
5
+ export const writeFileSchema = z.object({
6
+ path: z.string().describe('Path of the file to write'),
7
+ content: z.string().describe('The complete text content for the file'),
8
+ });
9
+ export async function runWriteFile(input) {
10
+ const resolved = resolveFromCwd(input.path);
11
+ // Assumption: missing parent folders are created so new files can land in new folders.
12
+ await mkdir(path.dirname(resolved), { recursive: true });
13
+ await fsWriteFile(resolved, input.content, 'utf8');
14
+ return `Wrote ${input.content.length} characters to ${input.path}.`;
15
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@tianmucreations/jeeves",
3
+ "version": "0.2.0",
4
+ "description": "A clean terminal assistant. Plain English in, job done. Works with any model.",
5
+ "author": {
6
+ "name": "tianmucreations",
7
+ "url": "https://tianmucreations.com"
8
+ },
9
+ "homepage": "https://tianmucreations.com",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/tianmucreations/jeeves.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/tianmucreations/jeeves/issues"
16
+ },
17
+ "license": "MIT",
18
+ "files": [
19
+ "bin/",
20
+ "dist/",
21
+ "README.md"
22
+ ],
23
+ "type": "module",
24
+ "bin": {
25
+ "jeeves": "bin/jeeves"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "dev": "tsx src/index.tsx",
32
+ "build": "tsc",
33
+ "start": "node dist/index.js",
34
+ "test": "vitest run"
35
+ },
36
+ "dependencies": {
37
+ "@ai-sdk/anthropic": "^4.0.54",
38
+ "@openrouter/ai-sdk-provider": "^3.0.0",
39
+ "ai": "^7.0.100",
40
+ "chalk": "^6.0.0",
41
+ "commander": "^15.0.0",
42
+ "conf": "^15.1.0",
43
+ "execa": "^10.0.1",
44
+ "fast-glob": "^3.3.3",
45
+ "fuse.js": "^7.5.0",
46
+ "ink": "^7.1.1",
47
+ "ink-spinner": "^5.0.0",
48
+ "keytar": "^7.9.0",
49
+ "node-notifier": "^10.0.1",
50
+ "react": "^19.3.0",
51
+ "signal-exit": "^3.0.7",
52
+ "zod": "^4.6.5"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^26.5.1",
56
+ "@types/node-notifier": "^8.0.5",
57
+ "@types/react": "^19.3.0",
58
+ "tsx": "^4.23.13",
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^5.0.0"
61
+ }
62
+ }