@vierratale/ai 0.1.0-beta.12 → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,11 +4,20 @@ Intelligent terminal assistant that runs AI models locally and in the cloud.
4
4
 
5
5
  ## Install
6
6
 
7
+ Install requirements and run from source:
8
+
7
9
  ```bash
8
- # From npm
9
- npm install -g @vierratale/ai
10
+ git clone <your-repo-url>/vierratale-ai.git
11
+ cd vierratale-ai
12
+
13
+ ./install.sh --node # installs requirements + links the commands
14
+ vierratale # start chatting
15
+ ```
10
16
 
11
- # Or run directly
17
+ Or run directly from npm:
18
+
19
+ ```bash
20
+ npm install -g @vierratale/ai
12
21
  npx @vierratale/ai
13
22
  ```
14
23
 
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // VierrataleAI launcher + installer.
3
3
  //
4
- // Running without flags starts the assistant (same as before).
5
- // Running with --install (or --setup / install) installs the engine + model and
6
- // creates a global "vierrataleai" command, then starts the assistant.
4
+ // Running without flags starts the assistant and auto-detects dependencies:
5
+ // if the AI engine (ollama) or the model is missing, they are installed first.
6
+ // Running with --install (or --setup / install) additionally creates a global
7
+ // "vierrataleai" / "vierratale" command, then starts the assistant.
7
8
  //
8
9
  // Installation progress is always the fake animated bar — real engine/model
9
10
  // output is deliberately hidden so first-run feels quick and clean.
@@ -12,6 +13,7 @@ import { Installer } from '../src/installer.js';
12
13
  import { Config } from '../src/config.js';
13
14
  import { Branding } from '../src/ui/branding.js';
14
15
  import { Terminal } from '../src/ui/terminal.js';
16
+ import { dirname } from 'path';
15
17
 
16
18
  const C = Branding.colors;
17
19
 
@@ -23,6 +25,8 @@ const INSTALL_STEPS = [
23
25
  'Optimizing for this device',
24
26
  ];
25
27
 
28
+ const READY_STEPS = ['Checking engine', 'Installing dependencies', 'Downloading model', 'Optimizing'];
29
+
26
30
  function delay(ms) {
27
31
  return new Promise((r) => setTimeout(r, ms));
28
32
  }
@@ -53,6 +57,7 @@ async function fakeProgress(steps, work) {
53
57
  async function main() {
54
58
  const args = process.argv.slice(2);
55
59
  const doInstall = args.includes('--install') || args.includes('--setup') || args.includes('install');
60
+ const quick = args.includes('--version') || args.includes('-v') || args.includes('--help') || args.includes('-h');
56
61
 
57
62
  Config.load();
58
63
 
@@ -60,11 +65,18 @@ async function main() {
60
65
  console.log(`\n ${C.bold}${C.primary}${Branding.APP_NAME} ${Branding.VERSION}${C.reset} ${C.dim}— installer${C.reset}\n`);
61
66
  const result = await fakeProgress(INSTALL_STEPS, Installer.install());
62
67
  if (!result.command) {
63
- Terminal.printInfo('Could not create a global "vierrataleai" command; run it via the bin script.');
68
+ Terminal.printInfo('Could not create a global command; run it via the bin script.');
64
69
  } else {
65
- Terminal.printSuccess(`Engine + model installed; "vierrataleai" command available at ${result.command}`);
70
+ Terminal.printSuccess(`Engine + model installed; "vierrataleai" (or "vierratale") command available at ${result.commandAlias || result.command}`);
71
+ if (!result.commandOnPath) {
72
+ Terminal.printInfo(`"vierrataleai" won't resolve in a new shell: add ${dirname(result.command)} to PATH, e.g. export PATH="$HOME/.local/bin:$PATH", then reopen the terminal.`);
73
+ }
66
74
  }
67
75
  console.log();
76
+ } else if (!quick) {
77
+ // Auto-detect: install anything that's missing (engine, model) before starting.
78
+ await fakeProgress(READY_STEPS, Installer.ensure());
79
+ console.log();
68
80
  }
69
81
 
70
82
  return run();
package/package.json CHANGED
@@ -1,31 +1,30 @@
1
1
  {
2
2
  "name": "@vierratale/ai",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.13",
4
4
  "description": "VierrataleAI - Intelligent terminal assistant",
5
5
  "type": "module",
6
- "files": [
7
- "bin/",
8
- "scripts/",
9
- "src/",
10
- "README.md"
11
- ],
12
- "bin": {
13
- "vierrataleai": "./bin/vierrataleai.js"
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=18"
14
9
  },
15
- "main": "./src/index.js",
16
- "exports": {
17
- ".": "./src/index.js"
10
+ "bin": {
11
+ "vierrataleai": "./bin/vierrataleai.js",
12
+ "vierratale": "./bin/vierrataleai.js"
18
13
  },
19
14
  "scripts": {
20
- "start": "node bin/vierrataleai.js",
21
- "test": "node --test \"test/*.test.js\"",
22
15
  "postinstall": "node scripts/postinstall.js"
23
16
  },
24
- "keywords": ["ai", "chatbot", "terminal", "assistant", "llm"],
25
- "author": "Vierratale",
26
- "license": "MIT",
27
- "engines": {
28
- "node": ">=18.0.0"
29
- },
30
- "dependencies": {}
31
- }
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "scripts",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "ai",
25
+ "chatbot",
26
+ "terminal",
27
+ "assistant",
28
+ "llm"
29
+ ]
30
+ }
package/src/cli.js CHANGED
@@ -47,6 +47,8 @@ Options:
47
47
  --version, -v Show version
48
48
  --help, -h Show this help
49
49
 
50
+ Missing dependencies (engine + model) are installed automatically on start.
51
+
50
52
  Commands (in chat):
51
53
  /help Show commands
52
54
  /model [name] Switch model
package/src/cmd/agent.js CHANGED
@@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'path';
4
4
  import { Config } from '../config.js';
5
5
  import { logger } from '../utils/logger.js';
6
- import { makeExecutor, dispatchTool, TOOL_EXECUTORS } from './tools.js';
6
+ import { renderFileTree } from '../utils/filetree.js';
7
+ import { makeExecutor, dispatchTool, TOOL_EXECUTORS, pickPackageManager } from './tools.js';
7
8
 
8
9
  const MAX_PLANNING_ROUNDS = 3;
9
10
  const MAX_STEPS = 8;
@@ -25,12 +26,13 @@ Reply with EXACTLY ONE JSON array of the tool calls needed to do the job, in ord
25
26
  {"name":"todo_clear"}
26
27
 
27
28
  Rules:
28
- - run_command runs WITHOUT a shell. Allowed commands: mkdir touch ls pwd cat cp mv rm npm npx node python python3 git echo printf.
29
+ - run_command runs WITHOUT a shell. Allowed commands: mkdir touch ls pwd cat cp mv rm npm npx node python python3 git echo printf apt apt-get pkg.
29
30
  - run_command supports arguments with spaces (quote them). No shell operators (&& | > ; etc.), no wildcards for rm, no inline -e/-c code for node/python, no paths outside the workspace.
30
31
  - To create a file containing code or text, ALWAYS use create_file (never echo/cat > …). Pass the full content in "content".
31
32
  - Prefer create_file over redirecting output.
32
33
  - If you need to create a folder first, plan create_directory before create_file inside it.
33
34
  - To download a file, web asset, archive or folder listing from the web, use download_url with the full URL. dir is optional (defaults to ~/Downloads).
35
+ - To install a system package the user asked for, use run_command with pkg (Termux/Android) or apt/apt-get (Linux), e.g. {"name":"run_command","command":"pkg install -y curl wget"}. The -y/--yes confirm flag is added automatically — never use && or sudo.
34
36
  - For multi-step work, track your progress with todo_add/todo_list/todo_update (e.g. todo_add for each remaining subtask, todo_update to mark steps done).
35
37
  - Code you create must be runnable. For Python servers bind to 127.0.0.1, read PORT from the environment (default 8000), and fall back to a free port (port 0) when the default is taken. HTML/CSS/JS must be self-contained and reference each other by relative filename.
36
38
  - If NO tools are needed, reply exactly: NONE
@@ -207,6 +209,24 @@ export function heuristicPlan(requestText) {
207
209
  return [{ name: 'run_command', params: { command: tail } }];
208
210
  }
209
211
 
212
+ // System package installs: explicit "pkg/apt install …" and generic
213
+ // "install <pkg>" requests ("pasang curl", "install wget").
214
+ const pkgInstall = /\b(pkg|apt-get|apt)\s+(install|i|add)\s+(.+)$/i.exec(text);
215
+ if (pkgInstall) {
216
+ const pkgs = pkgInstall[3].trim().split(/\s+/).slice(0, 8).filter((p) => /^[a-z0-9][a-z0-9.+_~-]*$/i.test(p));
217
+ if (pkgs.length) return [{ name: 'run_command', params: { command: `${pkgInstall[1]} install ${pkgs.join(' ')}` } }];
218
+ }
219
+ const INSTALL_BLOCKED = new Set(['project', 'proyek', 'folder', 'direktori', 'file', 'berkas', 'code', 'kode', 'dependencies', 'dependensi', 'app', 'aplikasi', 'gui', 'the', 'a', 'an', 'for', 'to', 'in', 'on', 'from', 'with', 'and', 'dan', 'lalu', 'semua', 'ini', 'itu', 'now', 'please', 'tolong']);
220
+ const genericInstall = /\b(install|pasang|installkan|menginstall|menginstal|set\s*up|setup)\b/i.exec(text);
221
+ const devInstallSpeak = /node modules|modules|dependenc\w*|package\.json|requirements(\.txt)?|virtualenv|pip\b|cargo\b|go get\b/gi;
222
+ if (genericInstall && !/\b(npm|yarn|pnpm|npx|bun|pip|pip3|uv|conda|apt|pkg)\b/i.test(text) && !devInstallSpeak.test(text)) {
223
+ const target = text.match(/\b(install|pasang|installkan|menginstall|menginstal|set\s*up|setup)\b\s+(?:(the|a|an|package|paket|tool|alat|program|utility|latest)\s+)?([a-z0-9][a-z0-9.+_~-]{1,63})\b/i);
224
+ if (target && !INSTALL_BLOCKED.has(target[3].toLowerCase())) {
225
+ const manager = pickPackageManager();
226
+ return [{ name: 'run_command', params: { command: `${manager} install ${target[3]}` } }];
227
+ }
228
+ }
229
+
210
230
  m = /\b(jalankan|run|execute|exec|kerjakan)\b\s+(node|python3?)\b\s*([A-Za-z0-9_.\/-]+(?:\.[A-Za-z0-9]+)?)?/i.exec(text);
211
231
  if (m) {
212
232
  const cmd = `${m[2]} ${m[3] || ''}`.trim();
@@ -1787,6 +1807,12 @@ export async function runWithTools({
1787
1807
  }
1788
1808
 
1789
1809
  if (results.length) {
1810
+ const madeFiles = results.some((r) => (r.name === 'create_file' || r.name === 'create_directory') && r.result.success);
1811
+ if (madeFiles) {
1812
+ const treeText = renderFileTree(cwd || process.cwd()).join('\n');
1813
+ logger.log('TREE', treeText.slice(0, 2500));
1814
+ if (chatUI) chatUI.notify(treeText);
1815
+ }
1790
1816
  if (chatUI) {
1791
1817
  chatUI.setStatus('');
1792
1818
  chatUI.setThinking(true);
@@ -4,8 +4,16 @@ import { dirname, resolve, sep } from 'path';
4
4
  const ALLOWED_BINS = new Set([
5
5
  'mkdir', 'touch', 'ls', 'pwd', 'cat', 'cp', 'mv', 'rm',
6
6
  'npm', 'npx', 'node', 'python', 'python3', 'git', 'echo', 'printf',
7
+ 'apt', 'apt-get', 'pkg',
7
8
  ]);
8
9
 
10
+ // Package-manager commands never run interactively (no TTY), so auto-append a
11
+ // confirm flag to mutating subcommands instead of letting apt/pkg block on a
12
+ // prompt. pkg is the Termux/Android wrapper, apt/apt-get the Linux ones.
13
+ const INSTALL_MANAGERS = new Set(['apt', 'apt-get', 'pkg']);
14
+ const INSTALL_SUBCOMMANDS = new Set(['install', 'upgrade', 'remove', 'autoremove', 'purge']);
15
+ const INSTALL_CONFIRM_FLAGS = /^-y$|^--yes$|^-dy$|^--assume-yes$/i;
16
+
9
17
  const SHELL_META = /[;&|<>`$]|\$\{|\(\)|\|\||&&/;
10
18
 
11
19
  // Bins whose *path arguments* must stay inside the workspace. Reads like `ls`
@@ -115,6 +123,12 @@ export class SafeCommandExecutor {
115
123
  if (PATH_CONTAINED_BINS.has(bin)) {
116
124
  this._checkPaths(bin, args);
117
125
  }
126
+ if (INSTALL_MANAGERS.has(bin)) {
127
+ const subIdx = argv.findIndex((a) => INSTALL_SUBCOMMANDS.has(a));
128
+ if (subIdx !== -1 && !argv.some((a) => INSTALL_CONFIRM_FLAGS.test(a))) {
129
+ argv.splice(subIdx + 1, 0, '-y');
130
+ }
131
+ }
118
132
  return argv;
119
133
  }
120
134
 
package/src/cmd/tools.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
- import { resolve, sep } from 'path';
1
+ import { accessSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, X_OK } from 'fs';
2
+ import { delimiter, join, resolve, sep } from 'path';
3
3
  import { toolResult, SafeCommandExecutor } from './executor.js';
4
4
  import { Downloader } from '../utils/downloader.js';
5
5
  import { Config } from '../config.js';
@@ -106,6 +106,23 @@ export function deleteFile(cwd, path) {
106
106
  }
107
107
  }
108
108
 
109
+ // Pick the package manager to use for installing system packages: pkg is the
110
+ // Termux/Android wrapper, everywhere else apt/apt-get.
111
+ export function pickPackageManager() {
112
+ const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean);
113
+ const available = (bin) => dirs.some((dir) => {
114
+ try {
115
+ accessSync(join(dir, bin), X_OK);
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ });
121
+ if (available('pkg')) return 'pkg';
122
+ if (available('apt-get')) return 'apt-get';
123
+ return 'apt';
124
+ }
125
+
109
126
  export async function downloadUrl(cwd, url, dir) {
110
127
  if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url.trim())) {
111
128
  return fail({ command: 'download_url', stderr: 'Invalid URL. Provide a valid http(s) address.', cwd });
package/src/installer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execSync, exec } from 'child_process';
2
- import { existsSync, mkdirSync, symlinkSync, chmodSync, writeFileSync } from 'fs';
3
- import { join } from 'path';
2
+ import { existsSync, mkdirSync, symlinkSync, chmodSync, writeFileSync, lstatSync, readlinkSync, rmSync, realpathSync } from 'fs';
3
+ import { join, dirname, resolve } from 'path';
4
+ import { fileURLToPath } from 'url';
4
5
  import { homedir, platform } from 'os';
5
6
  import { Catalog } from './catalog.js';
6
7
  import { Config } from './config.js';
@@ -112,19 +113,61 @@ function localBinDir() {
112
113
  return '/usr/local/bin';
113
114
  }
114
115
 
115
- function installAppCommand() {
116
+ function isOnPath(dir) {
117
+ const sep = platform() === 'win32' ? ';' : ':';
118
+ let dirResolved;
116
119
  try {
117
- const dir = localBinDir();
118
- mkdirSync(dir, { recursive: true, mode: 0o755 });
119
- const launcherPath = join(dir, 'vierrataleai');
120
- if (platform() === 'win32') {
121
- if (existsSync(launcherPath)) return launcherPath;
122
- writeFileSync(launcherPath, `@echo off\r\nnode "${process.argv[1]}" %*\r\n`);
123
- return launcherPath;
120
+ dirResolved = existsSync(dir) ? realpathSync(dir) : resolve(dir);
121
+ } catch {
122
+ dirResolved = resolve(dir);
123
+ }
124
+ return String(process.env.PATH || '')
125
+ .split(sep)
126
+ .some((entry) => {
127
+ entry = entry.trim();
128
+ if (!entry) return false;
129
+ try {
130
+ const e = existsSync(entry) ? realpathSync(entry) : resolve(entry);
131
+ return e === dirResolved;
132
+ } catch {
133
+ return false;
134
+ }
135
+ });
136
+ }
137
+
138
+ // Prefer a bin dir that is already on PATH (and writable) so the installed
139
+ // command is callable immediately; fall back to the local bin dir.
140
+ function chooseBinDir() {
141
+ const local = localBinDir();
142
+ if (isOnPath(local)) return local;
143
+ for (const candidate of ['/usr/local/bin', '/usr/bin']) {
144
+ try {
145
+ mkdirSync(candidate, { recursive: true, mode: 0o755 });
146
+ const probe = join(candidate, `.vierrataleai-write-test-${process.pid}`);
147
+ writeFileSync(probe, '');
148
+ rmSync(probe, { force: true });
149
+ return candidate;
150
+ } catch {}
151
+ }
152
+ return local;
153
+ }
154
+
155
+ // Create (or refresh) a launcher link for `name` pointing at the node bin.
156
+ function writeLauncher(dir, name, target) {
157
+ const launcherPath = join(dir, name);
158
+ if (platform() === 'win32') {
159
+ if (!existsSync(launcherPath)) {
160
+ writeFileSync(launcherPath, `@echo off\r\nnode "${target}" %*\r\n`);
124
161
  }
125
- // Prefer a real symlink; fall back to a small shim that calls the node bin.
126
- if (existsSync(launcherPath)) return launcherPath;
127
- const target = process.argv[1] && existsSync(process.argv[1]) ? process.argv[1] : join(Config.getConfigDir(), '..', '..', 'src', 'cli.js');
162
+ return launcherPath;
163
+ }
164
+ // Prefer a real symlink; fall back to a small shim that calls the node bin.
165
+ let live = false;
166
+ try {
167
+ live = lstatSync(launcherPath).isSymbolicLink() && readlinkSync(launcherPath) === target;
168
+ } catch {}
169
+ if (!live) {
170
+ rmSync(launcherPath, { force: true });
128
171
  try {
129
172
  symlinkSync(target, launcherPath);
130
173
  chmodSync(launcherPath, 0o755);
@@ -132,6 +175,19 @@ function installAppCommand() {
132
175
  writeFileSync(launcherPath, `#!/bin/sh\nnode "${target}" "$@"\n`);
133
176
  chmodSync(launcherPath, 0o755);
134
177
  }
178
+ }
179
+ return launcherPath;
180
+ }
181
+
182
+ function installAppCommand() {
183
+ try {
184
+ const dir = chooseBinDir();
185
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
186
+ const defaultTarget = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'vierrataleai.js');
187
+ const target = process.argv[1] && existsSync(process.argv[1]) ? process.argv[1] : defaultTarget;
188
+ // Both spellings run the assistant.
189
+ const launcherPath = writeLauncher(dir, 'vierrataleai', target);
190
+ writeLauncher(dir, 'vierratale', target);
135
191
  return launcherPath;
136
192
  } catch {
137
193
  return null;
@@ -158,6 +214,9 @@ export const Installer = {
158
214
 
159
215
  chooseModel,
160
216
 
217
+ installAppCommand,
218
+ isOnPath,
219
+
161
220
  async ensure() {
162
221
  const host = Config.get('engineHost');
163
222
 
@@ -227,7 +286,15 @@ export const Installer = {
227
286
  }
228
287
 
229
288
  let command = null;
230
- if (includeCommand) command = installAppCommand();
231
- return { installed: await isEngineInstalled(), running: await isEngineRunning(host), command };
289
+ let commandAlias = null;
290
+ let commandOnPath = false;
291
+ if (includeCommand) {
292
+ command = installAppCommand();
293
+ if (command) {
294
+ commandAlias = join(dirname(command), 'vierratale');
295
+ commandOnPath = isOnPath(dirname(command));
296
+ }
297
+ }
298
+ return { installed: await isEngineInstalled(), running: await isEngineRunning(host), command, commandAlias, commandOnPath };
232
299
  },
233
300
  };
@@ -0,0 +1,44 @@
1
+ import { readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ // Directories that are never worth expanding in a workspace tree.
5
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.cache']);
6
+
7
+ // Render a compact box-drawing tree of a directory. Returns an array of lines
8
+ // starting with "." for the workspace root. Bounded so huge folders (node_modules,
9
+ // caches, download dirs) can never flood a chat with thousands of lines.
10
+ export function renderFileTree(rootDir, { maxDepth = 4, maxEntries = 150 } = {}) {
11
+ const root = rootDir || process.cwd();
12
+ const lines = ['.'];
13
+ let entries = 0;
14
+
15
+ const walk = (dir, depth, prefix) => {
16
+ if (entries >= maxEntries) return;
17
+ let names;
18
+ try {
19
+ names = readdirSync(dir, { withFileTypes: true });
20
+ } catch {
21
+ return;
22
+ }
23
+ names = names
24
+ .filter((e) => !SKIP_DIRS.has(e.name))
25
+ .sort((a, b) => {
26
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
27
+ return a.name.localeCompare(b.name);
28
+ });
29
+ for (let i = 0; i < names.length; i++) {
30
+ if (entries >= maxEntries) return;
31
+ const e = names[i];
32
+ const last = i === names.length - 1;
33
+ lines.push(`${prefix}${last ? '└── ' : '├── '}${e.name}`);
34
+ entries++;
35
+ if (e.isDirectory() && depth < maxDepth) {
36
+ walk(join(dir, e.name), depth + 1, `${prefix}${last ? ' ' : '│ '}`);
37
+ }
38
+ }
39
+ };
40
+
41
+ walk(root, 0, '');
42
+ if (entries >= maxEntries) lines.push(`… showing first ${entries} entries`);
43
+ return lines;
44
+ }