ai-discovery-manager-cli 0.1.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.
@@ -0,0 +1,166 @@
1
+ import { tool } from "@openai/agents";
2
+ import { readdir, readFile, stat, writeFile, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ const MAX_READ_BYTES = 256 * 1024;
6
+ const MAX_WRITE_BYTES = 1 * 1024 * 1024;
7
+ const MAX_LIST_ENTRIES = 500;
8
+ const IGNORED_FILENAMES = new Set([
9
+ "package-lock.json",
10
+ "pnpm-lock.yaml",
11
+ "yarn.lock",
12
+ "bun.lockb",
13
+ "npm-shrinkwrap.json",
14
+ ]);
15
+ const IGNORED_SEGMENTS = new Set([".git", "node_modules"]);
16
+ const SECRET_NAME_PATTERN = /(^\.env($|\.)|secret|credential|password|passwd|token|api[-_]?key|private[-_]?key|\.pem$|\.key$|\.p12$|\.pfx$)/i;
17
+ export function resolveInside(workspaceRoot, relPath) {
18
+ const cleaned = (relPath ?? "").replace(/^[/\\]+/, "");
19
+ const resolved = path.resolve(workspaceRoot, cleaned);
20
+ const rel = path.relative(workspaceRoot, resolved);
21
+ if (rel === "") {
22
+ return resolved;
23
+ }
24
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
25
+ throw new Error(`Path escapes workspace: ${relPath}. Use paths relative to the workspace root.`);
26
+ }
27
+ return resolved;
28
+ }
29
+ export function toPosix(p) {
30
+ return p.split(path.sep).join("/");
31
+ }
32
+ function isIgnoredWorkspacePath(relPath) {
33
+ const posix = toPosix(relPath).replace(/^\.?\//, "");
34
+ const parts = posix.split("/").filter(Boolean);
35
+ if (parts.some((part) => IGNORED_SEGMENTS.has(part))) {
36
+ return true;
37
+ }
38
+ const filename = parts.at(-1) ?? "";
39
+ return IGNORED_FILENAMES.has(filename) || SECRET_NAME_PATTERN.test(filename);
40
+ }
41
+ function assertModelReadablePath(workspaceRoot, target, requested) {
42
+ const rel = toPosix(path.relative(workspaceRoot, target) || ".");
43
+ if (isIgnoredWorkspacePath(rel)) {
44
+ throw new Error(`Refusing to expose ignored or secret-like workspace path to the model: ${requested}`);
45
+ }
46
+ }
47
+ function looksBinary(buffer) {
48
+ const limit = Math.min(buffer.length, 8192);
49
+ for (let i = 0; i < limit; i += 1) {
50
+ if (buffer[i] === 0x00) {
51
+ return true;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ /**
57
+ * Read a UTF-8 text file from inside `workspaceRoot`, enforcing the same sandbox
58
+ * (`resolveInside`), size cap (`MAX_READ_BYTES`), and binary guard (`looksBinary`)
59
+ * as the `read_workspace_file` tool. Shared so the interactive `/read` command and
60
+ * the agent tool stay consistent.
61
+ */
62
+ export async function readWorkspaceTextFile(workspaceRoot, relPath) {
63
+ const target = resolveInside(workspaceRoot, relPath);
64
+ assertModelReadablePath(workspaceRoot, target, relPath);
65
+ const info = await stat(target);
66
+ if (!info.isFile()) {
67
+ throw new Error(`Not a file: ${relPath}`);
68
+ }
69
+ const buffer = await readFile(target);
70
+ if (looksBinary(buffer)) {
71
+ throw new Error(`Refusing to read likely-binary file: ${relPath}. Tools only handle UTF-8 text.`);
72
+ }
73
+ const truncated = buffer.length > MAX_READ_BYTES;
74
+ const slice = truncated ? buffer.subarray(0, MAX_READ_BYTES) : buffer;
75
+ return {
76
+ path: toPosix(path.relative(workspaceRoot, target)),
77
+ bytes: info.size,
78
+ truncated,
79
+ content: slice.toString("utf8"),
80
+ };
81
+ }
82
+ export function createWorkspaceTools(options) {
83
+ const { workspaceRoot, allowWrites } = options;
84
+ const listTool = tool({
85
+ name: "list_workspace",
86
+ description: "List files and subdirectories in the research workspace. Pass a relative path (default: workspace root). Returns entries with name, type (file|dir), and size in bytes for files.",
87
+ parameters: z.object({
88
+ path: z
89
+ .string()
90
+ .describe("Relative path inside the workspace. Use '' or '.' for the workspace root.")
91
+ .default("."),
92
+ }),
93
+ async execute({ path: relPath }) {
94
+ const target = resolveInside(workspaceRoot, relPath || ".");
95
+ assertModelReadablePath(workspaceRoot, target, relPath || ".");
96
+ const info = await stat(target);
97
+ if (!info.isDirectory()) {
98
+ throw new Error(`Not a directory: ${relPath}`);
99
+ }
100
+ const entries = await readdir(target, { withFileTypes: true });
101
+ const visibleEntries = entries.filter((entry) => {
102
+ const rel = toPosix(path.relative(workspaceRoot, path.join(target, entry.name)));
103
+ return !isIgnoredWorkspacePath(rel);
104
+ });
105
+ const limited = visibleEntries.slice(0, MAX_LIST_ENTRIES);
106
+ const rows = await Promise.all(limited.map(async (entry) => {
107
+ const child = path.join(target, entry.name);
108
+ const childInfo = await stat(child).catch(() => undefined);
109
+ const type = entry.isDirectory()
110
+ ? "dir"
111
+ : entry.isFile()
112
+ ? "file"
113
+ : "other";
114
+ return {
115
+ name: entry.name,
116
+ type,
117
+ size: type === "file" && childInfo ? childInfo.size : undefined,
118
+ };
119
+ }));
120
+ return JSON.stringify({
121
+ path: toPosix(path.relative(workspaceRoot, target) || "."),
122
+ truncated: entries.length > MAX_LIST_ENTRIES,
123
+ entries: rows,
124
+ }, null, 2);
125
+ },
126
+ });
127
+ const readToolDef = tool({
128
+ name: "read_workspace_file",
129
+ description: "Read a UTF-8 text file from the workspace. Returns up to ~256 KiB. Use list_workspace first to discover paths.",
130
+ parameters: z.object({
131
+ path: z.string().describe("Path to the file, relative to the workspace root."),
132
+ }),
133
+ async execute({ path: relPath }) {
134
+ const result = await readWorkspaceTextFile(workspaceRoot, relPath);
135
+ return JSON.stringify(result, null, 2);
136
+ },
137
+ });
138
+ const tools = [listTool, readToolDef];
139
+ if (allowWrites) {
140
+ const writeToolDef = tool({
141
+ name: "write_workspace_file",
142
+ description: "Write a UTF-8 text file inside the workspace. Creates parent directories as needed. Overwrites existing files. Max 1 MiB.",
143
+ parameters: z.object({
144
+ path: z
145
+ .string()
146
+ .describe("Destination path, relative to the workspace root."),
147
+ content: z.string().describe("UTF-8 text contents to write."),
148
+ }),
149
+ async execute({ path: relPath, content }) {
150
+ const target = resolveInside(workspaceRoot, relPath);
151
+ const byteLength = Buffer.byteLength(content, "utf8");
152
+ if (byteLength > MAX_WRITE_BYTES) {
153
+ throw new Error(`Refusing to write ${byteLength} bytes; limit is ${MAX_WRITE_BYTES}.`);
154
+ }
155
+ await mkdir(path.dirname(target), { recursive: true });
156
+ await writeFile(target, content, "utf8");
157
+ return JSON.stringify({
158
+ path: toPosix(path.relative(workspaceRoot, target)),
159
+ bytesWritten: byteLength,
160
+ });
161
+ },
162
+ });
163
+ tools.push(writeToolDef);
164
+ }
165
+ return tools;
166
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "ai-discovery-manager-cli",
3
+ "version": "0.1.0",
4
+ "description": "Codex-style research manager CLI using OpenAI Agents SDK specialist agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ai-discovery": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "README.md",
12
+ "package.json"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "dev": "tsx src/cli.ts",
17
+ "start": "node dist/cli.js",
18
+ "dry-run": "tsx src/cli.ts run --dry-run --topic \"Example thesis topic\""
19
+ },
20
+ "dependencies": {
21
+ "@openai/agents": "^0.11.5",
22
+ "zod": "^4.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.15.0",
26
+ "tsx": "^4.19.0",
27
+ "typescript": "^5.8.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ }
32
+ }