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.
This commit is contained in:
104
cli.js
Normal file
104
cli.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
58
package.json
Normal file
58
package.json
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
16
systemprompt.txt
Normal file
16
systemprompt.txt
Normal file
@@ -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.
|
||||||
|
|
||||||
15
tools/echo.js
Normal file
15
tools/echo.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
184
tools/list_files.js
Normal file
184
tools/list_files.js
Normal file
@@ -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)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user