commit f6f809263ccf7a78fc5fc77c750bd3401dac8619 Author: sebseb7 Date: Mon Aug 11 15:21:01 2025 +0200 Initial commit: Add CLI tool with OpenAI integration, including file listing and echo functionalities. Introduce package.json for dependencies and scripts, and system prompt for AI behavior. diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..daa423e --- /dev/null +++ b/cli.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import 'dotenv/config'; +import OpenAI from 'openai'; + +import { promises as fs } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function loadTools() { + const toolsDir = path.join(__dirname, "tools"); + const dirents = await fs.readdir(toolsDir, { withFileTypes: true }); + const toolEntries = await Promise.all( + dirents + .filter((dirent) => dirent.isFile() && dirent.name.endsWith(".js")) + .map(async (dirent) => { + const fileName = dirent.name.replace(/\.js$/, ""); + const module = await import(`file://${path.join(toolsDir, dirent.name)}`); + return [fileName, { def: module.default, run: module.run }]; + }) + ); + return Object.fromEntries(toolEntries); +} + + + + +streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), 'Zeig mir die Dateiein in /'); + +async function streamOnce(openai, userText) { + const toolsByFile = await loadTools(); + + const input =[ + { "role": "developer", "content": [ {"type": "input_text","text": '' }] }, + {"role": "user", "content": [ { "type": "input_text", "text": userText } ]}, + ] + + const call = { + model: 'gpt-5-nano', + input, + text: { format: { type: 'text' }, verbosity: 'low' }, + reasoning: { effort: 'high', summary: 'detailed' }, + tools: Object.values(toolsByFile).map(t => t.def), + store: true, + } + + const stream = await openai.responses.stream(call); + + stream.on('response.created', (event) => { + //console.log('respid:', event.response.id); + }); + stream.on('response.reasoning_summary_text.delta', (event) => { + //process.stdout.write(event.delta); + }); + stream.on('response.reasoning_summary_text.done', () => { + //process.stdout.write('\n'); + //clear on next delta + }); + + stream.on('response.output_text.delta', (event) => { + process.stdout.write(event.delta); + }); + + + stream.on('response.output_item.added', (event) => { + if(event.item && event.item.type === 'function_call'){ + //console.log('function call:', event.item); + } + }); + stream.on('response.function_call_arguments.delta', (event) => { + //process.stdout.write(event.delta); + }); + + const functionCalls = []; + + stream.on('response.output_item.done', async (event) => { + if(event.item && event.item.type === 'function_call'){ + const id = event.item.id; + const name = event.item.name; + let args = {}; + try { + args = JSON.parse(event.item.arguments); + } catch (e){ + console.error('Error parsing arguments:', e, event.item.arguments); + } + console.log('function call:', id,name, args,await toolsByFile[name].run(args)); + } + }); + + stream.on('response.completed', (event) => { + const filtered = { + id: event?.response?.id, + status: event?.response?.status, + output: event?.response?.output, + usage: event?.response?.usage, + }; + //console.log('OPENAI RESPONSE:', event); + }); + + await Array.fromAsync(stream); + + console.log('OPENAI STREAM FINISHED'); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bea1caf --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "aitools", + "version": "1.0.0", + "type": "module", + "main": "cli.js", + "dependencies": { + "dotenv": "^16.4.5", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.6.0", + "asynckit": "^0.4.0", + "call-bind-apply-helpers": "^1.0.2", + "chalk": "^5.5.0", + "combined-stream": "^1.0.8", + "delayed-stream": "^1.0.0", + "dunder-proto": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "esbuild": "^0.25.8", + "event-target-shim": "^5.0.1", + "form-data": "^4.0.4", + "form-data-encoder": "^1.7.2", + "formdata-node": "^4.4.1", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-tsconfig": "^4.10.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2", + "humanize-ms": "^1.2.1", + "math-intrinsics": "^1.1.0", + "mime-db": "^1.52.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "node-domexception": "^1.0.0", + "node-fetch": "^2.7.0", + "openai": "^4.104.0", + "resolve-pkg-maps": "^1.0.0", + "tr46": "^0.0.3", + "tsx": "^4.20.3", + "typescript": "^5.9.2", + "undici-types": "^5.26.5", + "web-streams-polyfill": "^4.0.0-beta.3", + "webidl-conversions": "^3.0.1", + "whatwg-url": "^5.0.0" + }, + "scripts": { + "start": "node cli.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/systemprompt.txt b/systemprompt.txt new file mode 100644 index 0000000..8efcf29 --- /dev/null +++ b/systemprompt.txt @@ -0,0 +1,16 @@ +You are an interactive CLI AI assistant. Follow the user's instructions. +If a tool is available and relevant, plan to use it. +Be explicit when information is undefined. +Do not silently fall back: surface errors. + +Prefer concise answers. + +Developer rules: +- Null tells the truth. If data is missing/undefined, say so; do not invent values. +- In development, never hide errors; include warnings if using fallbacks. + +Behavior: +- Answer succinctly. +- Ask for clarification when the user input is ambiguous. +- Output plain text suitable for a terminal. + diff --git a/tools/echo.js b/tools/echo.js new file mode 100644 index 0000000..2e213ba --- /dev/null +++ b/tools/echo.js @@ -0,0 +1,15 @@ +export default { + type: "function", name: "echo", description: "Echoes back the input string provided", strict: true, + parameters: { + type: "object", additionalProperties: false, required: [ "input" ], + properties: { "input": { type: "string", description: "The text to be echoed back" } } + } +}; + +export async function run(args) { + const text = typeof args?.text === 'string' ? args.text : ''; + if (!text) { + return { error: 'Missing required parameter: text' }; + } + return { content: text }; +} diff --git a/tools/list_files.js b/tools/list_files.js new file mode 100644 index 0000000..f2706cf --- /dev/null +++ b/tools/list_files.js @@ -0,0 +1,184 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +// Utility to normalize and validate paths within chroot +const normalizePath = (inputPath, chrootDir) => { + // Resolve chroot directory + const chrootResolved = path.resolve(chrootDir); + + // Handle root path ("/") as the chroot directory itself + if (inputPath === "/" || inputPath === "") { + return chrootResolved; + } + + // Resolve input path relative to chroot directory + const resolved = path.join(chrootResolved, inputPath); + const normalized = path.normalize(resolved); + + // Ensure the path is within chrootDir + if (!normalized.startsWith(chrootResolved)) { + throw new Error(`Path escapes chroot boundary: ${inputPath}`); + } + + return normalized; +}; + +// Main recursive directory listing function +async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) { + const results = []; + const seen = new Set(); // Track paths to prevent symlink cycles + + async function walk(currentDir, currentDepth) { + if (currentDepth > maxDepth) return; + + let dirents; + try { + dirents = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (err) { + throw new Error(`Failed to read directory: ${currentDir} (${err?.message || String(err)})`); + } + + for (const dirent of dirents) { + // Skip hidden files/directories unless includeHidden is true + if (!includeHidden && dirent.name.startsWith(".")) continue; + + const fullPath = path.join(currentDir, dirent.name); + // Ensure fullPath is within chroot + if (!fullPath.startsWith(path.resolve(chrootDir))) { + continue; // Skip paths outside chroot + } + + const relPath = path.relative(startDir, fullPath) || "."; + + // Prevent symlink cycles + if (dirent.isSymbolicLink()) { + const realPath = await fs.realpath(fullPath).catch(() => null); + // Ensure realPath is within chroot + if (realPath && !realPath.startsWith(path.resolve(chrootDir))) { + continue; // Skip symlinks pointing outside chroot + } + if (realPath && seen.has(realPath)) continue; + if (realPath) seen.add(realPath); + results.push([relPath, "l", null]); + continue; // Don't recurse into symlinks + } + + if (dirent.isDirectory()) { + seen.add(fullPath); + results.push([relPath, "d", null]); + if (currentDepth < maxDepth) { + await walk(fullPath, currentDepth + 1); + } + } else if (dirent.isFile()) { + let size = null; + try { + const stat = await fs.stat(fullPath); + size = stat.size; + } catch { + // Size remains null if stat fails + } + results.push([relPath, "f", size]); + } else { + results.push([relPath, "o", null]); + } + } + } + + await walk(startDir, 0); + return results; +} + +export default { + type: "function", + name: "list_files", + description: "List files and directories recursively within a chroot directory with customizable options", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.", + }, + depth: { + type: "integer", + description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.", + minimum: -1, + }, + includeHidden: { + type: "boolean", + description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.", + default: false, + }, + chroot: { + type: "string", + description: "Root directory to confine all operations (chroot). All paths are resolved relative to this directory.", + }, + }, + required: ["path", "depth", "includeHidden", "chroot"], + additionalProperties: false, + }, + strict: true, +}; + +export async function run(args) { + const inputPath = args?.path || ""; + const depth = Number.isInteger(args?.depth) ? args.depth : 1; + const includeHidden = args?.includeHidden ?? false; + const chrootPath = '/home/seb/src/aiTools/tmp'; + + if (!chrootPath) { + return { err: "Chroot path is required" }; + } + if (depth < -1) { + return { err: `Depth must be >= -1, received ${args?.depth}` }; + } + + let chrootResolved; + try { + chrootResolved = path.resolve(chrootPath); + await fs.access(chrootResolved); // Ensure chroot path exists + } catch (err) { + return { err: `Invalid chroot path: ${chrootPath} (${err?.message || String(err)})` }; + } + + let resolvedBase; + try { + resolvedBase = normalizePath(inputPath, chrootResolved); + } catch (err) { + return { err: err.message }; + } + + let stat; + try { + stat = await fs.lstat(resolvedBase); + } catch (err) { + return { err: `Path does not exist: ${resolvedBase} (${err?.message || String(err)})` }; + } + + const cwd = path.relative(chrootResolved, stat.isFile() ? path.dirname(resolvedBase) : resolvedBase) || "."; + + // Handle single file case + if (stat.isFile()) { + const fileName = path.basename(resolvedBase); + if (!includeHidden && fileName.startsWith(".")) { + return { cwd, files: [] }; + } + return { cwd, files: [[fileName, "f", stat.size]] }; + } + + // Handle non-directory case + if (!stat.isDirectory()) { + return { err: `Not a file or directory: ${resolvedBase}` }; + } + + // Handle directory case + try { + const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden); + return { + cwd, + files: files.sort((a, b) => a[0].localeCompare(b[0])), // Sort for consistent output + }; + } catch (err) { + return { err: `Failed to list files: ${err?.message || String(err)}` }; + } +} \ No newline at end of file