qwall-work 1.0.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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/SECURITY.md +5 -0
- package/bin/qwall-work.js +8 -0
- package/docs/configuration.md +15 -0
- package/docs/models.md +17 -0
- package/docs/security.md +7 -0
- package/docs/tools.md +13 -0
- package/package.json +40 -0
- package/src/ai/chat.js +56 -0
- package/src/ai/client.js +12 -0
- package/src/ai/models.js +23 -0
- package/src/ai/streaming.js +15 -0
- package/src/ai/tool-handler.js +13 -0
- package/src/ai/tools.js +59 -0
- package/src/cli/commands.js +41 -0
- package/src/cli/help.js +25 -0
- package/src/cli/prompts.js +26 -0
- package/src/cli/renderer.js +11 -0
- package/src/cli/repl.js +72 -0
- package/src/config/api-key.js +2 -0
- package/src/config/index.js +40 -0
- package/src/config/model.js +2 -0
- package/src/config/paths.js +5 -0
- package/src/constants/commands.js +6 -0
- package/src/constants/version.js +3 -0
- package/src/index.js +7 -0
- package/src/providers/google/client.js +9 -0
- package/src/providers/google/errors.js +11 -0
- package/src/providers/google/models.js +2 -0
- package/src/session/context.js +8 -0
- package/src/session/history.js +12 -0
- package/src/session/index.js +2 -0
- package/src/tools/edit-file.js +14 -0
- package/src/tools/index.js +7 -0
- package/src/tools/list-files.js +12 -0
- package/src/tools/nano.js +16 -0
- package/src/tools/read-file.js +8 -0
- package/src/tools/write-file.js +11 -0
- package/src/utils/errors.js +8 -0
- package/src/utils/logger.js +4 -0
- package/src/utils/paths.js +3 -0
- package/src/utils/platform.js +7 -0
- package/src/workspace/index.js +9 -0
- package/src/workspace/manager.js +9 -0
- package/src/workspace/security.js +16 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codetable Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Qwall Work
|
|
2
|
+
|
|
3
|
+
AI workspace CLI powered by the Google Gemini API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Gemini and Gemma model discovery
|
|
8
|
+
- User-owned Gemini API key
|
|
9
|
+
- Interactive AI chat
|
|
10
|
+
- File read/write/edit/list tools
|
|
11
|
+
- Optional nano integration
|
|
12
|
+
- Workspace path protection
|
|
13
|
+
- CommonJS Node.js CLI
|
|
14
|
+
- npm global installation
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js >= 18
|
|
19
|
+
- A Google Gemini API key
|
|
20
|
+
- `nano` only if you want the `open_in_nano` tool
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g qwall-work
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
qwall
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Configure:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
qwall-work config
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
List available Gemini/Gemma models:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
qwall-work models
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Workspace
|
|
47
|
+
|
|
48
|
+
By default, AI file operations are restricted to:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
<current-directory>/.qwall-workspace
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
You can choose another workspace:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
QWALL_WORKSPACE=/path/to/project qwall
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT - Copyright (c) Codetable Software
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Set the API key with `qwall-work config` or:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
export GEMINI_API_KEY="your-key"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Optional model:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
export QWALL_MODEL="gemini-2.5-flash"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Configuration is stored in `~/.qwall-work/config.json` with restrictive file permissions.
|
package/docs/models.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Models
|
|
2
|
+
|
|
3
|
+
Qwall Work queries the Google Gemini API model list and filters it to Gemini and Gemma models that expose content generation.
|
|
4
|
+
|
|
5
|
+
Use:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
qwall-work models
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then select a model with:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
/model
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Availability depends on the user's API key and Google's current API catalog.
|
package/docs/security.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
The AI file tools are restricted to the Qwall Work workspace. Parent-directory traversal such as `../secret` is rejected.
|
|
4
|
+
|
|
5
|
+
Never commit or publish an API key. Use `GEMINI_API_KEY` or the local Qwall Work configuration.
|
|
6
|
+
|
|
7
|
+
The model can request file tools, so review generated changes when working on important projects.
|
package/docs/tools.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Tools
|
|
2
|
+
|
|
3
|
+
Qwall Work exposes these AI tools:
|
|
4
|
+
|
|
5
|
+
- `read_file`
|
|
6
|
+
- `write_file`
|
|
7
|
+
- `edit_file`
|
|
8
|
+
- `list_files`
|
|
9
|
+
- `open_in_nano`
|
|
10
|
+
|
|
11
|
+
All file paths are constrained to the configured Qwall Work workspace.
|
|
12
|
+
|
|
13
|
+
`open_in_nano` requires the `nano` executable to be installed and available on PATH.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qwall-work",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI workspace CLI powered by Google Gemini API",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"qwall-work": "./bin/qwall-work.js",
|
|
9
|
+
"qwall": "./bin/qwall-work.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"docs",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"SECURITY.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node test/workspace/security.test.js && node test/tools/write-file.test.js && node test/tools/edit-file.test.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@google/genai": "^1.20.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"qwall",
|
|
31
|
+
"qwall-work",
|
|
32
|
+
"ai",
|
|
33
|
+
"cli",
|
|
34
|
+
"gemini",
|
|
35
|
+
"gemma",
|
|
36
|
+
"google"
|
|
37
|
+
],
|
|
38
|
+
"author": "Codetable Software",
|
|
39
|
+
"license": "MIT"
|
|
40
|
+
}
|
package/src/ai/chat.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const { getClient } = require("./client");
|
|
2
|
+
const { buildTools } = require("./tools");
|
|
3
|
+
const { executeTool } = require("./tool-handler");
|
|
4
|
+
const { getWorkspace } = require("../workspace");
|
|
5
|
+
|
|
6
|
+
async function ask({ model, input, history, onTool }) {
|
|
7
|
+
const ai = getClient();
|
|
8
|
+
const contents = [...history, { role: "user", parts: [{ text: input }] }];
|
|
9
|
+
|
|
10
|
+
const response = await ai.models.generateContent({
|
|
11
|
+
model,
|
|
12
|
+
contents,
|
|
13
|
+
config: {
|
|
14
|
+
tools: buildTools()
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const candidate = response.candidates?.[0];
|
|
19
|
+
const parts = candidate?.content?.parts || [];
|
|
20
|
+
const toolResults = [];
|
|
21
|
+
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
if (part.functionCall) {
|
|
24
|
+
const call = part.functionCall;
|
|
25
|
+
onTool?.({ name: call.name, status: "running" });
|
|
26
|
+
const result = await executeTool(call.name, call.args || {}, getWorkspace());
|
|
27
|
+
toolResults.push({ name: call.name, result });
|
|
28
|
+
onTool?.({ name: call.name, path: call.args?.path, status: "completed" });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let text = parts.filter(p => p.text).map(p => p.text).join("");
|
|
33
|
+
let nextHistory = [...contents];
|
|
34
|
+
|
|
35
|
+
if (toolResults.length) {
|
|
36
|
+
const functionResponses = toolResults.map(({ name, result }) => ({
|
|
37
|
+
functionResponse: { name, response: result }
|
|
38
|
+
}));
|
|
39
|
+
nextHistory.push({ role: "model", parts: parts.filter(p => p.functionCall) });
|
|
40
|
+
nextHistory.push({ role: "user", parts: functionResponses });
|
|
41
|
+
|
|
42
|
+
const followup = await ai.models.generateContent({
|
|
43
|
+
model,
|
|
44
|
+
contents: nextHistory,
|
|
45
|
+
config: { tools: buildTools() }
|
|
46
|
+
});
|
|
47
|
+
text = followup.text || text;
|
|
48
|
+
nextHistory.push(followup.candidates?.[0]?.content || { role: "model", parts: [{ text }] });
|
|
49
|
+
} else {
|
|
50
|
+
nextHistory.push(candidate?.content || { role: "model", parts: [{ text }] });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { text, history: nextHistory };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { ask };
|
package/src/ai/client.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { GoogleGenAI } = require("@google/genai");
|
|
2
|
+
const config = require("../config");
|
|
3
|
+
|
|
4
|
+
function getClient() {
|
|
5
|
+
const apiKey = config.getApiKey();
|
|
6
|
+
if (!apiKey) {
|
|
7
|
+
throw new Error("Gemini API key is not configured. Run `qwall-work config` or set GEMINI_API_KEY.");
|
|
8
|
+
}
|
|
9
|
+
return new GoogleGenAI({ apiKey });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { getClient };
|
package/src/ai/models.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { getClient } = require("./client");
|
|
2
|
+
|
|
3
|
+
async function fetchModels() {
|
|
4
|
+
const ai = getClient();
|
|
5
|
+
const result = [];
|
|
6
|
+
for await (const model of await ai.models.list()) {
|
|
7
|
+
const name = model.name || "";
|
|
8
|
+
const methods = model.supportedActions || model.supportedGenerationMethods || [];
|
|
9
|
+
const supported = methods.length === 0 || methods.some(m =>
|
|
10
|
+
String(m).toLowerCase().includes("generatecontent")
|
|
11
|
+
);
|
|
12
|
+
if (supported && (/gemini|gemma/i.test(name) || /gemini|gemma/i.test(model.displayName || ""))) {
|
|
13
|
+
result.push({
|
|
14
|
+
name,
|
|
15
|
+
displayName: model.displayName || name,
|
|
16
|
+
description: model.description || ""
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { fetchModels };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { getClient } = require("./client");
|
|
2
|
+
|
|
3
|
+
async function streamText({ model, contents, onText }) {
|
|
4
|
+
const ai = getClient();
|
|
5
|
+
const stream = await ai.models.generateContentStream({ model, contents });
|
|
6
|
+
let full = "";
|
|
7
|
+
for await (const chunk of stream) {
|
|
8
|
+
const text = chunk.text || "";
|
|
9
|
+
full += text;
|
|
10
|
+
onText?.(text);
|
|
11
|
+
}
|
|
12
|
+
return full;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { streamText };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const tools = require("../tools");
|
|
2
|
+
|
|
3
|
+
async function executeTool(name, args, workspace) {
|
|
4
|
+
switch (name) {
|
|
5
|
+
case "read_file": return { content: await tools.readFile(args.path, workspace) };
|
|
6
|
+
case "write_file": return await tools.writeFile(args.path, args.content, workspace);
|
|
7
|
+
case "edit_file": return await tools.editFile(args.path, args.oldText, args.newText, workspace);
|
|
8
|
+
case "list_files": return { files: await tools.listFiles(args.path || ".", workspace) };
|
|
9
|
+
case "open_in_nano": return await tools.openNano(args.path, workspace);
|
|
10
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
module.exports = { executeTool };
|
package/src/ai/tools.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function buildTools() {
|
|
2
|
+
return [{
|
|
3
|
+
functionDeclarations: [
|
|
4
|
+
{
|
|
5
|
+
name: "read_file",
|
|
6
|
+
description: "Read a UTF-8 text file inside the Qwall Work workspace.",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "OBJECT",
|
|
9
|
+
properties: { path: { type: "STRING", description: "Relative file path." } },
|
|
10
|
+
required: ["path"]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "write_file",
|
|
15
|
+
description: "Create or replace a UTF-8 text file inside the Qwall Work workspace.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "OBJECT",
|
|
18
|
+
properties: {
|
|
19
|
+
path: { type: "STRING", description: "Relative file path." },
|
|
20
|
+
content: { type: "STRING", description: "Complete file content." }
|
|
21
|
+
},
|
|
22
|
+
required: ["path", "content"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "edit_file",
|
|
27
|
+
description: "Replace an exact text fragment in a UTF-8 file inside the workspace.",
|
|
28
|
+
parameters: {
|
|
29
|
+
type: "OBJECT",
|
|
30
|
+
properties: {
|
|
31
|
+
path: { type: "STRING" },
|
|
32
|
+
oldText: { type: "STRING" },
|
|
33
|
+
newText: { type: "STRING" }
|
|
34
|
+
},
|
|
35
|
+
required: ["path", "oldText", "newText"]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "list_files",
|
|
40
|
+
description: "List files and directories inside the workspace.",
|
|
41
|
+
parameters: {
|
|
42
|
+
type: "OBJECT",
|
|
43
|
+
properties: { path: { type: "STRING", description: "Relative directory path." } }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "open_in_nano",
|
|
48
|
+
description: "Open a workspace file in the nano editor. Use when the user explicitly asks to edit with nano.",
|
|
49
|
+
parameters: {
|
|
50
|
+
type: "OBJECT",
|
|
51
|
+
properties: { path: { type: "STRING" } },
|
|
52
|
+
required: ["path"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { buildTools };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { startRepl } = require("./repl");
|
|
2
|
+
const { showHelp } = require("./help");
|
|
3
|
+
const { getVersion } = require("../constants/version");
|
|
4
|
+
const config = require("../config");
|
|
5
|
+
const { fetchModels } = require("../ai/models");
|
|
6
|
+
|
|
7
|
+
async function runCLI(args) {
|
|
8
|
+
const command = args[0];
|
|
9
|
+
|
|
10
|
+
if (args.includes("--help") || command === "help") {
|
|
11
|
+
showHelp();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (args.includes("--version") || command === "version") {
|
|
16
|
+
console.log(`Qwall Work ${getVersion()}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (command === "config") {
|
|
21
|
+
await config.interactive();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (command === "models") {
|
|
26
|
+
const models = await fetchModels();
|
|
27
|
+
for (const model of models) {
|
|
28
|
+
console.log(`${model.displayName} (${model.name})`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await startRepl({ initialModel: getOption(args, "--model") });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getOption(args, flag) {
|
|
37
|
+
const index = args.indexOf(flag);
|
|
38
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { runCLI };
|
package/src/cli/help.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function showHelp() {
|
|
2
|
+
console.log(`
|
|
3
|
+
Qwall Work - AI workspace CLI
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
qwall-work
|
|
7
|
+
qwall-work config
|
|
8
|
+
qwall-work models
|
|
9
|
+
qwall-work --model <model>
|
|
10
|
+
qwall-work --help
|
|
11
|
+
qwall-work --version
|
|
12
|
+
|
|
13
|
+
Inside chat:
|
|
14
|
+
/model Select a Gemini/Gemma model
|
|
15
|
+
/clear Clear conversation history
|
|
16
|
+
/help Show commands
|
|
17
|
+
/exit Exit
|
|
18
|
+
|
|
19
|
+
Environment:
|
|
20
|
+
GEMINI_API_KEY Google Gemini API key
|
|
21
|
+
QWALL_WORKSPACE Optional workspace path
|
|
22
|
+
QWALL_MODEL Optional default model
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
module.exports = { showHelp };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
const { fetchModels } = require("../ai/models");
|
|
3
|
+
const config = require("../config");
|
|
4
|
+
|
|
5
|
+
function question(prompt) {
|
|
6
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
7
|
+
return new Promise(resolve => rl.question(prompt, answer => {
|
|
8
|
+
rl.close();
|
|
9
|
+
resolve(answer.trim());
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function selectModel() {
|
|
14
|
+
const models = await fetchModels();
|
|
15
|
+
if (!models.length) throw new Error("No Gemini/Gemma models are available for this API key.");
|
|
16
|
+
|
|
17
|
+
console.log("\nAvailable models:");
|
|
18
|
+
models.forEach((m, i) => console.log(`${i + 1}. ${m.displayName} (${m.name})`));
|
|
19
|
+
|
|
20
|
+
const answer = await question(`Select model [1-${models.length}] (current: ${config.getModel() || "none"}): `);
|
|
21
|
+
const index = Number(answer) - 1;
|
|
22
|
+
if (!Number.isInteger(index) || !models[index]) throw new Error("Invalid model selection.");
|
|
23
|
+
return models[index].name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { selectModel };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function renderText(text) {
|
|
2
|
+
if (text) process.stdout.write(`\nQwall > ${text}\n`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function renderTool(tool) {
|
|
6
|
+
console.log(`\n● Tool: ${tool.name}`);
|
|
7
|
+
if (tool.path) console.log(` File: ${tool.path}`);
|
|
8
|
+
if (tool.status) console.log(` ${tool.status}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { renderText, renderTool };
|
package/src/cli/repl.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
const { ask } = require("../ai/chat");
|
|
3
|
+
const { renderText, renderTool } = require("./renderer");
|
|
4
|
+
const { selectModel } = require("./prompts");
|
|
5
|
+
const config = require("../config");
|
|
6
|
+
const { getWorkspace } = require("../workspace");
|
|
7
|
+
|
|
8
|
+
async function startRepl({ initialModel } = {}) {
|
|
9
|
+
let model = initialModel || config.getModel();
|
|
10
|
+
const workspace = getWorkspace();
|
|
11
|
+
|
|
12
|
+
console.log("╭────────────────────────────────────────╮");
|
|
13
|
+
console.log("│ Qwall Work │");
|
|
14
|
+
console.log("│ Gemini & Gemma AI CLI │");
|
|
15
|
+
console.log("╰────────────────────────────────────────╯");
|
|
16
|
+
console.log(`Model: ${model}`);
|
|
17
|
+
console.log(`Workspace: ${workspace}`);
|
|
18
|
+
console.log("Commands: /model /clear /help /exit");
|
|
19
|
+
|
|
20
|
+
const rl = readline.createInterface({
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: process.stdout,
|
|
23
|
+
prompt: "\nYou > "
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let history = [];
|
|
27
|
+
rl.prompt();
|
|
28
|
+
|
|
29
|
+
for await (const line of rl) {
|
|
30
|
+
const input = line.trim();
|
|
31
|
+
if (!input) { rl.prompt(); continue; }
|
|
32
|
+
|
|
33
|
+
if (input === "/exit" || input === "/quit") break;
|
|
34
|
+
if (input === "/help") {
|
|
35
|
+
console.log("/model Select a Gemini/Gemma model");
|
|
36
|
+
console.log("/clear Clear conversation history");
|
|
37
|
+
console.log("/exit Exit Qwall Work");
|
|
38
|
+
rl.prompt();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (input === "/clear") {
|
|
42
|
+
history = [];
|
|
43
|
+
console.log("Conversation cleared.");
|
|
44
|
+
rl.prompt();
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (input === "/model") {
|
|
48
|
+
model = await selectModel();
|
|
49
|
+
config.setModel(model);
|
|
50
|
+
console.log(`Model: ${model}`);
|
|
51
|
+
rl.prompt();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const result = await ask({
|
|
57
|
+
model,
|
|
58
|
+
input,
|
|
59
|
+
history,
|
|
60
|
+
onTool: renderTool
|
|
61
|
+
});
|
|
62
|
+
renderText(result.text);
|
|
63
|
+
history = result.history;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`\nError: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
rl.prompt();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
rl.close();
|
|
71
|
+
}
|
|
72
|
+
module.exports = { startRepl };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
|
|
5
|
+
const DIR = path.join(os.homedir(), ".qwall-work");
|
|
6
|
+
const FILE = path.join(DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
function load() {
|
|
9
|
+
try { return JSON.parse(fs.readFileSync(FILE, "utf8")); }
|
|
10
|
+
catch { return {}; }
|
|
11
|
+
}
|
|
12
|
+
function save(data) {
|
|
13
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
14
|
+
fs.writeFileSync(FILE, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
15
|
+
}
|
|
16
|
+
function getApiKey() {
|
|
17
|
+
return process.env.GEMINI_API_KEY || load().apiKey || "";
|
|
18
|
+
}
|
|
19
|
+
function setApiKey(apiKey) {
|
|
20
|
+
const data = load(); data.apiKey = apiKey; save(data);
|
|
21
|
+
}
|
|
22
|
+
function getModel() {
|
|
23
|
+
return process.env.QWALL_MODEL || load().model || "gemini-2.5-flash";
|
|
24
|
+
}
|
|
25
|
+
function setModel(model) {
|
|
26
|
+
const data = load(); data.model = model; save(data);
|
|
27
|
+
}
|
|
28
|
+
async function interactive() {
|
|
29
|
+
const readline = require("readline");
|
|
30
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
31
|
+
const ask = p => new Promise(resolve => rl.question(p, resolve));
|
|
32
|
+
const current = getApiKey();
|
|
33
|
+
const key = await ask(`Gemini API key ${current ? "(configured, press Enter to keep)" : ""}: `);
|
|
34
|
+
if (key.trim()) setApiKey(key.trim());
|
|
35
|
+
const model = await ask(`Model [${getModel()}]: `);
|
|
36
|
+
if (model.trim()) setModel(model.trim());
|
|
37
|
+
rl.close();
|
|
38
|
+
console.log("Configuration saved.");
|
|
39
|
+
}
|
|
40
|
+
module.exports = { getApiKey, setApiKey, getModel, setModel, interactive };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const { GoogleGenAI } = require("@google/genai");
|
|
2
|
+
const config = require("../../config");
|
|
3
|
+
|
|
4
|
+
function createGoogleClient() {
|
|
5
|
+
const key = config.getApiKey();
|
|
6
|
+
if (!key) throw new Error("GEMINI_API_KEY is not configured.");
|
|
7
|
+
return new GoogleGenAI({ apiKey: key });
|
|
8
|
+
}
|
|
9
|
+
module.exports = { createGoogleClient };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function normalizeGoogleError(error) {
|
|
2
|
+
const message = error?.message || String(error);
|
|
3
|
+
if (/api.?key|unauthorized|permission/i.test(message)) {
|
|
4
|
+
return new Error("Google API authentication failed. Check your Gemini API key.");
|
|
5
|
+
}
|
|
6
|
+
if (/quota|rate.?limit|resource.?exhausted/i.test(message)) {
|
|
7
|
+
return new Error("Google Gemini API quota or rate limit was reached.");
|
|
8
|
+
}
|
|
9
|
+
return new Error(message);
|
|
10
|
+
}
|
|
11
|
+
module.exports = { normalizeGoogleError };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
function createSession() {
|
|
2
|
+
return { messages: [] };
|
|
3
|
+
}
|
|
4
|
+
function addMessage(session, message) {
|
|
5
|
+
session.messages.push(message);
|
|
6
|
+
return session;
|
|
7
|
+
}
|
|
8
|
+
function clearSession(session) {
|
|
9
|
+
session.messages.length = 0;
|
|
10
|
+
return session;
|
|
11
|
+
}
|
|
12
|
+
module.exports = { createSession, addMessage, clearSession };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const { resolveSafePath } = require("../workspace/security");
|
|
3
|
+
|
|
4
|
+
async function editFile(relativePath, oldText, newText, workspace) {
|
|
5
|
+
const target = resolveSafePath(workspace, relativePath);
|
|
6
|
+
const content = await fs.readFile(target, "utf8");
|
|
7
|
+
if (!content.includes(oldText)) {
|
|
8
|
+
throw new Error(`Text to replace was not found in ${relativePath}.`);
|
|
9
|
+
}
|
|
10
|
+
const updated = content.replace(oldText, newText);
|
|
11
|
+
await fs.writeFile(target, updated, "utf8");
|
|
12
|
+
return { ok: true, path: relativePath };
|
|
13
|
+
}
|
|
14
|
+
module.exports = { editFile };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const { readFile } = require("./read-file");
|
|
2
|
+
const { writeFile } = require("./write-file");
|
|
3
|
+
const { editFile } = require("./edit-file");
|
|
4
|
+
const { listFiles } = require("./list-files");
|
|
5
|
+
const { openNano } = require("./nano");
|
|
6
|
+
|
|
7
|
+
module.exports = { readFile, writeFile, editFile, listFiles, openNano };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const { resolveSafePath } = require("../workspace/security");
|
|
3
|
+
|
|
4
|
+
async function listFiles(relativePath, workspace) {
|
|
5
|
+
const target = resolveSafePath(workspace, relativePath);
|
|
6
|
+
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
7
|
+
return entries.map(entry => ({
|
|
8
|
+
name: entry.name,
|
|
9
|
+
type: entry.isDirectory() ? "directory" : "file"
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
module.exports = { listFiles };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { spawn } = require("child_process");
|
|
2
|
+
const { resolveSafePath } = require("../workspace/security");
|
|
3
|
+
|
|
4
|
+
function openNano(relativePath, workspace) {
|
|
5
|
+
const target = resolveSafePath(workspace, relativePath);
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn("nano", [target], { stdio: "inherit" });
|
|
8
|
+
child.on("error", error => reject(new Error(`Could not start nano: ${error.message}`)));
|
|
9
|
+
child.on("exit", code => {
|
|
10
|
+
if (code === 0) resolve({ ok: true, path: relativePath });
|
|
11
|
+
else reject(new Error(`nano exited with code ${code}.`));
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { openNano };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const { resolveSafePath } = require("../workspace/security");
|
|
3
|
+
|
|
4
|
+
async function readFile(relativePath, workspace) {
|
|
5
|
+
const target = resolveSafePath(workspace, relativePath);
|
|
6
|
+
return fs.readFile(target, "utf8");
|
|
7
|
+
}
|
|
8
|
+
module.exports = { readFile };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { resolveSafePath } = require("../workspace/security");
|
|
4
|
+
|
|
5
|
+
async function writeFile(relativePath, content, workspace) {
|
|
6
|
+
const target = resolveSafePath(workspace, relativePath);
|
|
7
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
8
|
+
await fs.writeFile(target, String(content), "utf8");
|
|
9
|
+
return { ok: true, path: relativePath, bytes: Buffer.byteLength(String(content), "utf8") };
|
|
10
|
+
}
|
|
11
|
+
module.exports = { writeFile };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const os = require("os");
|
|
2
|
+
function platform() { return process.platform; }
|
|
3
|
+
function isWindows() { return process.platform === "win32"; }
|
|
4
|
+
function isLinux() { return process.platform === "linux"; }
|
|
5
|
+
function isMacOS() { return process.platform === "darwin"; }
|
|
6
|
+
function homeDir() { return os.homedir(); }
|
|
7
|
+
module.exports = { platform, isWindows, isLinux, isMacOS, homeDir };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
function getWorkspace() {
|
|
5
|
+
const workspace = process.env.QWALL_WORKSPACE || path.join(process.cwd(), ".qwall-workspace");
|
|
6
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
7
|
+
return path.resolve(workspace);
|
|
8
|
+
}
|
|
9
|
+
module.exports = { getWorkspace };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const { getWorkspace } = require("./index");
|
|
3
|
+
|
|
4
|
+
async function ensureWorkspace() {
|
|
5
|
+
const workspace = getWorkspace();
|
|
6
|
+
await fs.mkdir(workspace, { recursive: true });
|
|
7
|
+
return workspace;
|
|
8
|
+
}
|
|
9
|
+
module.exports = { ensureWorkspace };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
function resolveSafePath(workspace, relativePath) {
|
|
4
|
+
if (typeof relativePath !== "string" || !relativePath.trim()) {
|
|
5
|
+
throw new Error("A file path is required.");
|
|
6
|
+
}
|
|
7
|
+
const root = path.resolve(workspace);
|
|
8
|
+
const target = path.resolve(root, relativePath);
|
|
9
|
+
const relative = path.relative(root, target);
|
|
10
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
11
|
+
throw new Error("Path escapes the Qwall Work workspace.");
|
|
12
|
+
}
|
|
13
|
+
return target;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { resolveSafePath };
|