@danieltmn/tamnora 0.7.1 → 0.8.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 +12 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +2 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/server/public/app.js +367 -61
- package/harness/packages/server/public/index.html +32 -1
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/styles.css +54 -18
- package/harness/packages/server/src/routes/chat.js +3 -0
- package/harness/packages/server/src/routes/git.js +43 -0
- package/harness/packages/server/src/routes/procs.js +14 -2
- package/harness/packages/server/src/routes/shell.js +23 -0
- package/harness/packages/server/src/server.js +4 -0
- package/harness/packages/server/test/server.test.js +106 -0
- package/package.json +1 -1
|
@@ -450,8 +450,8 @@
|
|
|
450
450
|
<h1>Tamnora<br /><span>Harness</span></h1>
|
|
451
451
|
|
|
452
452
|
<p class="hero-copy">
|
|
453
|
-
Un harness
|
|
454
|
-
|
|
453
|
+
Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
|
|
454
|
+
control desde tu máquina o el celular (túnel + QR o Telegram).
|
|
455
455
|
</p>
|
|
456
456
|
|
|
457
457
|
<div class="actions">
|
|
@@ -462,15 +462,15 @@
|
|
|
462
462
|
<div class="features" id="caracteristicas">
|
|
463
463
|
<div class="feature">
|
|
464
464
|
<div class="feature-icon">◈</div>
|
|
465
|
-
<div><b>
|
|
465
|
+
<div><b>Local</b><span>Corre en tu máquina; el estado vive en tu casa.</span></div>
|
|
466
466
|
</div>
|
|
467
467
|
<div class="feature">
|
|
468
468
|
<div class="feature-icon">↗</div>
|
|
469
|
-
<div><b>Accesible</b><span>
|
|
469
|
+
<div><b>Accesible</b><span>Consola web, TUI y Telegram; túnel con QR.</span></div>
|
|
470
470
|
</div>
|
|
471
471
|
<div class="feature">
|
|
472
472
|
<div class="feature-icon">✓</div>
|
|
473
|
-
<div><b>Control</b><span>
|
|
473
|
+
<div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
|
|
474
474
|
</div>
|
|
475
475
|
</div>
|
|
476
476
|
</section>
|
|
@@ -445,53 +445,61 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
445
445
|
.msg-menu-btn {
|
|
446
446
|
display: inline-grid;
|
|
447
447
|
place-items: center;
|
|
448
|
-
width:
|
|
449
|
-
|
|
450
|
-
padding:
|
|
448
|
+
width: 26px;
|
|
449
|
+
height: 24px;
|
|
450
|
+
padding: 0;
|
|
451
451
|
background: transparent;
|
|
452
|
-
border: 1px solid
|
|
452
|
+
border: 1px solid transparent;
|
|
453
453
|
border-radius: 7px;
|
|
454
454
|
color: var(--muted);
|
|
455
455
|
cursor: pointer;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
transition: opacity 0.12s ease;
|
|
456
|
+
opacity: 0.4;
|
|
457
|
+
transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;
|
|
459
458
|
}
|
|
460
459
|
.msg:hover .msg-menu-btn,
|
|
461
460
|
.msg:focus-within .msg-menu-btn,
|
|
462
461
|
.msg-menu-btn[aria-expanded='true'] { opacity: 1; }
|
|
463
462
|
.msg-menu-btn:hover,
|
|
464
|
-
.msg-menu-btn[aria-expanded='true'] { color: var(--text);
|
|
463
|
+
.msg-menu-btn[aria-expanded='true'] { color: var(--text); background: var(--theirs); }
|
|
465
464
|
.msg-menu {
|
|
466
465
|
position: fixed;
|
|
467
466
|
top: 0;
|
|
468
467
|
left: 0;
|
|
469
468
|
z-index: 60;
|
|
470
|
-
min-width:
|
|
469
|
+
min-width: 178px;
|
|
471
470
|
display: flex;
|
|
472
471
|
flex-direction: column;
|
|
473
|
-
gap:
|
|
472
|
+
gap: 1px;
|
|
474
473
|
padding: 5px;
|
|
475
474
|
border: 1px solid var(--border);
|
|
476
|
-
border-radius:
|
|
477
|
-
background: var(--panel);
|
|
478
|
-
box-shadow: 0
|
|
475
|
+
border-radius: 11px;
|
|
476
|
+
background: var(--solid, var(--panel));
|
|
477
|
+
box-shadow: 0 14px 34px rgb(0 0 0 / 0.24);
|
|
478
|
+
backdrop-filter: blur(18px);
|
|
479
479
|
}
|
|
480
480
|
.msg-menu[hidden] { display: none; }
|
|
481
|
+
.msg-menu-sep { height: 1px; margin: 4px 6px; background: var(--border); }
|
|
481
482
|
.msg-actions button.msg-act {
|
|
482
483
|
width: 100%;
|
|
484
|
+
display: flex;
|
|
485
|
+
align-items: center;
|
|
486
|
+
gap: 8px;
|
|
483
487
|
text-align: left;
|
|
484
488
|
background: transparent;
|
|
485
|
-
border:
|
|
489
|
+
border: 0;
|
|
486
490
|
border-radius: 7px;
|
|
487
|
-
color: var(--
|
|
488
|
-
font: 500
|
|
489
|
-
padding:
|
|
491
|
+
color: var(--text);
|
|
492
|
+
font: 500 12.5px var(--sans);
|
|
493
|
+
padding: 6px 8px;
|
|
490
494
|
cursor: pointer;
|
|
491
495
|
}
|
|
492
|
-
.msg-
|
|
496
|
+
.msg-act .mi { flex: 0 0 auto; color: var(--muted); }
|
|
497
|
+
.msg-act .mlabel { min-width: 0; }
|
|
498
|
+
.msg-actions button.msg-act:hover { background: var(--theirs); }
|
|
499
|
+
.msg-actions button.msg-act:hover .mi { color: var(--accent); }
|
|
493
500
|
.msg-actions button.msg-act.danger { color: var(--danger); }
|
|
494
501
|
.msg-actions button.msg-act.danger:hover { background: var(--theirs); }
|
|
502
|
+
.msg-actions button.msg-act.danger:hover .mi { color: var(--danger); }
|
|
495
503
|
.bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
|
|
496
504
|
|
|
497
505
|
.tools-actions { display: flex; align-items: center; gap: 8px; }
|
|
@@ -710,6 +718,14 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
|
|
|
710
718
|
.btn.icon svg { width: 18px; height: 18px; display: block; }
|
|
711
719
|
.btn.icon:hover { color: var(--accent); }
|
|
712
720
|
|
|
721
|
+
.chat-queue { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 9px; background: var(--theirs); }
|
|
722
|
+
.chat-queue[hidden] { display: none; }
|
|
723
|
+
.queue-label { color: var(--muted); font: 600 10.5px var(--mono); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
724
|
+
.queue-item { display: flex; align-items: center; gap: 8px; }
|
|
725
|
+
.queue-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 12.5px var(--sans); }
|
|
726
|
+
.queue-remove { flex: 0 0 auto; width: 20px; height: 20px; padding: 0; line-height: 1; border-radius: 50%; border: 0; background: transparent; color: var(--muted); cursor: pointer; }
|
|
727
|
+
.queue-remove:hover { color: var(--danger); background: var(--theirs); }
|
|
728
|
+
.shell-out { margin: 0; padding: 8px 10px; max-height: 320px; overflow: auto; background: var(--theirs); border: 1px solid var(--border); border-radius: 8px; font: 11.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; }
|
|
713
729
|
.attach-preview { display: flex; gap: 8px; flex-wrap: wrap; padding-bottom: 2px; }
|
|
714
730
|
.attach-thumb { position: relative; }
|
|
715
731
|
.attach-thumb img { height: 64px; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
|
@@ -1049,6 +1065,26 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1049
1065
|
#xp-preview.preview-maximized .preview-stage { border-radius: 10px; }
|
|
1050
1066
|
#procs-list { flex: 1; min-height: 0; overflow-y: auto; }
|
|
1051
1067
|
.procs-log { margin: 0; padding: 8px 12px; height: 150px; overflow: auto; background: var(--theirs); border-top: 1px solid var(--border); font: 500 11px var(--mono); white-space: pre-wrap; word-break: break-word; }
|
|
1068
|
+
.git-branch { font: 700 12px var(--mono); color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1069
|
+
.git-list { list-style: none; margin: 0; padding: 0 8px 10px; overflow-y: auto; flex: 1; min-height: 0; }
|
|
1070
|
+
.git-group { padding: 8px 6px 3px; color: var(--muted); font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
1071
|
+
.git-item { border-radius: 7px; }
|
|
1072
|
+
.git-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 7px; cursor: pointer; }
|
|
1073
|
+
.git-row:hover { background: var(--theirs); }
|
|
1074
|
+
.git-badge { flex: 0 0 auto; width: 16px; text-align: center; font: 700 11px var(--mono); }
|
|
1075
|
+
.git-badge.mod, .git-badge.ren { color: var(--accent); }
|
|
1076
|
+
.git-badge.add, .git-badge.new { color: var(--ok); }
|
|
1077
|
+
.git-badge.del, .git-badge.conf { color: var(--danger); }
|
|
1078
|
+
.git-badge.ign { color: var(--muted); }
|
|
1079
|
+
.git-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font: 500 12px var(--sans); }
|
|
1080
|
+
.git-diff { margin: 2px 0 8px; padding: 8px 10px; max-height: 320px; overflow: auto; background: var(--theirs); border-radius: 8px; font: 11px/1.5 var(--mono); white-space: pre; }
|
|
1081
|
+
.git-diff.err { color: var(--danger); }
|
|
1082
|
+
.git-diff .diff-line { display: block; }
|
|
1083
|
+
.git-diff .diff-add { color: var(--ok); }
|
|
1084
|
+
.git-diff .diff-del { color: var(--danger); }
|
|
1085
|
+
.git-diff .diff-eq { color: var(--muted); }
|
|
1086
|
+
.git-diff .diff-sep { color: var(--accent); opacity: 0.8; }
|
|
1087
|
+
.git-diff .diff-meta { color: var(--muted); opacity: 0.7; }
|
|
1052
1088
|
.import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
|
|
1053
1089
|
.import-bar input { flex: 1; min-width: 0; }
|
|
1054
1090
|
|
|
@@ -264,6 +264,9 @@ export function registerChat(route, ctx) {
|
|
|
264
264
|
} else if (event.type === 'retry') {
|
|
265
265
|
partials.flush();
|
|
266
266
|
ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
|
|
267
|
+
} else if (event.type === 'usage') {
|
|
268
|
+
partials.flush();
|
|
269
|
+
ctx.runs.emit(sessionId, 'usage', { tokens: event.usage });
|
|
267
270
|
} else if (event.type === 'trim') {
|
|
268
271
|
partials.flush();
|
|
269
272
|
ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { gitStatus, gitDiff } from '../lib/core.js';
|
|
3
|
+
import { sendJson, sendError } from '../lib/http.js';
|
|
4
|
+
import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
|
|
5
|
+
|
|
6
|
+
function isInside(root, target) {
|
|
7
|
+
const rel = path.relative(root, target);
|
|
8
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function registerGit(route, ctx) {
|
|
12
|
+
const resolveRoot = (req, res, raw) => {
|
|
13
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
14
|
+
sendError(res, 403, 'El estado de git solo está disponible en conexiones locales.');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return resolveExistingDir(raw || ctx.cwd);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
sendError(res, 400, error.message);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
route('GET', '/api/git/status', async ({ req, res, query }) => {
|
|
26
|
+
const root = resolveRoot(req, res, query.get('cwd'));
|
|
27
|
+
if (!root) return;
|
|
28
|
+
const status = await gitStatus(root);
|
|
29
|
+
sendJson(res, 200, { root, ...status });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
route('GET', '/api/git/diff', async ({ req, res, query }) => {
|
|
33
|
+
const root = resolveRoot(req, res, query.get('cwd'));
|
|
34
|
+
if (!root) return;
|
|
35
|
+
const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
|
|
36
|
+
if (rel && !isInside(root, path.resolve(root, rel))) {
|
|
37
|
+
return sendError(res, 400, 'La ruta queda fuera del workspace.');
|
|
38
|
+
}
|
|
39
|
+
const result = await gitDiff(root, { path: rel, staged: query.get('staged') === '1' });
|
|
40
|
+
if (!result.ok) return sendError(res, 400, result.error ?? 'No se pudo obtener el diff.');
|
|
41
|
+
sendJson(res, 200, { root, path: rel || null, ...result });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { detectProcs } from '../lib/core.js';
|
|
1
|
+
import { detectProcs, killPort } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
-
import { resolveExistingDir } from '../lib/workspace.js';
|
|
3
|
+
import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
|
|
4
4
|
|
|
5
5
|
export function registerProcs(route, ctx) {
|
|
6
6
|
route('GET', '/api/procs', ({ res }) => {
|
|
@@ -44,4 +44,16 @@ export function registerProcs(route, ctx) {
|
|
|
44
44
|
if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
|
|
45
45
|
sendJson(res, 200, { ok: true });
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
route('POST', '/api/procs/kill', async ({ req, res }) => {
|
|
49
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
50
|
+
return sendError(res, 403, 'Matar procesos solo está disponible en conexiones locales.');
|
|
51
|
+
}
|
|
52
|
+
const body = await readJson(req);
|
|
53
|
+
try {
|
|
54
|
+
sendJson(res, 200, await killPort(body.port));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
sendError(res, 400, error.message);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
47
59
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createTools } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
|
|
4
|
+
|
|
5
|
+
export function registerShell(route, ctx) {
|
|
6
|
+
route('POST', '/api/shell', async ({ req, res }) => {
|
|
7
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
8
|
+
return sendError(res, 403, 'El shell solo está disponible en conexiones locales.');
|
|
9
|
+
}
|
|
10
|
+
const body = await readJson(req);
|
|
11
|
+
const command = String(body.command ?? '').trim();
|
|
12
|
+
if (!command) return sendError(res, 400, 'Falta "command".');
|
|
13
|
+
let root;
|
|
14
|
+
try {
|
|
15
|
+
root = resolveExistingDir(body.cwd || ctx.cwd);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return sendError(res, 400, error.message);
|
|
18
|
+
}
|
|
19
|
+
const tools = createTools({ root, enabled: ['bash'], yes: true });
|
|
20
|
+
const output = await tools.execute('bash', { command });
|
|
21
|
+
sendJson(res, 200, { command, cwd: root, output: String(output ?? '') });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -28,12 +28,14 @@ import { registerAuth } from './routes/auth.js';
|
|
|
28
28
|
import { registerSessions } from './routes/sessions.js';
|
|
29
29
|
import { registerFolders } from './routes/folders.js';
|
|
30
30
|
import { registerFiles } from './routes/files.js';
|
|
31
|
+
import { registerGit } from './routes/git.js';
|
|
31
32
|
import { registerModels } from './routes/models.js';
|
|
32
33
|
import { registerProviders } from './routes/providers.js';
|
|
33
34
|
import { registerMcp } from './routes/mcp.js';
|
|
34
35
|
import { registerSkills } from './routes/skills.js';
|
|
35
36
|
import { registerChannels } from './routes/channels.js';
|
|
36
37
|
import { registerProcs } from './routes/procs.js';
|
|
38
|
+
import { registerShell } from './routes/shell.js';
|
|
37
39
|
import { registerPreview } from './routes/preview.js';
|
|
38
40
|
import { registerChat } from './routes/chat.js';
|
|
39
41
|
import { registerEval } from './routes/eval.js';
|
|
@@ -212,12 +214,14 @@ export async function createTamnoraServer({
|
|
|
212
214
|
registerSessions(route, ctx);
|
|
213
215
|
registerFolders(route, ctx);
|
|
214
216
|
registerFiles(route, ctx);
|
|
217
|
+
registerGit(route, ctx);
|
|
215
218
|
registerModels(route, ctx);
|
|
216
219
|
registerProviders(route, ctx);
|
|
217
220
|
registerMcp(route, ctx);
|
|
218
221
|
registerSkills(route, ctx);
|
|
219
222
|
registerChannels(route, ctx);
|
|
220
223
|
registerProcs(route, ctx);
|
|
224
|
+
registerShell(route, ctx);
|
|
221
225
|
const preview = registerPreview(route, ctx);
|
|
222
226
|
registerChat(route, ctx);
|
|
223
227
|
registerEval(route, ctx);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import http from 'node:http';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
4
5
|
import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
|
|
5
6
|
import { tmpdir } from 'node:os';
|
|
6
7
|
import path from 'node:path';
|
|
@@ -30,6 +31,21 @@ async function startServer(overrides = {}) {
|
|
|
30
31
|
return { instance, base: info.url };
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
const HAS_GIT = (() => {
|
|
35
|
+
try {
|
|
36
|
+
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
|
|
43
|
+
function gitInit(root) {
|
|
44
|
+
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
45
|
+
execFileSync('git', ['config', 'user.email', 'test@tamnora.dev'], { cwd: root });
|
|
46
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
test('serves health, providers and the console', async () => {
|
|
34
50
|
const { instance, base } = await startServer();
|
|
35
51
|
try {
|
|
@@ -570,6 +586,46 @@ test('detects, runs and stops dev processes', async () => {
|
|
|
570
586
|
}
|
|
571
587
|
});
|
|
572
588
|
|
|
589
|
+
test('runs a console shell command and kills a port', async () => {
|
|
590
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shell-'));
|
|
591
|
+
const { instance, base } = await startServer({ cwd });
|
|
592
|
+
const json = { 'Content-Type': 'application/json' };
|
|
593
|
+
try {
|
|
594
|
+
const shell = await fetch(`${base}/api/shell`, {
|
|
595
|
+
method: 'POST',
|
|
596
|
+
headers: json,
|
|
597
|
+
body: JSON.stringify({ command: 'echo hola-shell' }),
|
|
598
|
+
});
|
|
599
|
+
assert.equal(shell.status, 200);
|
|
600
|
+
const out = await shell.json();
|
|
601
|
+
assert.match(out.output, /hola-shell/);
|
|
602
|
+
|
|
603
|
+
const missing = await fetch(`${base}/api/shell`, {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: json,
|
|
606
|
+
body: JSON.stringify({ command: ' ' }),
|
|
607
|
+
});
|
|
608
|
+
assert.equal(missing.status, 400);
|
|
609
|
+
|
|
610
|
+
const badPort = await fetch(`${base}/api/procs/kill`, {
|
|
611
|
+
method: 'POST',
|
|
612
|
+
headers: json,
|
|
613
|
+
body: JSON.stringify({ port: 0 }),
|
|
614
|
+
});
|
|
615
|
+
assert.equal(badPort.status, 400);
|
|
616
|
+
|
|
617
|
+
const none = await fetch(`${base}/api/procs/kill`, {
|
|
618
|
+
method: 'POST',
|
|
619
|
+
headers: json,
|
|
620
|
+
body: JSON.stringify({ port: 47555 }),
|
|
621
|
+
});
|
|
622
|
+
assert.equal(none.status, 200);
|
|
623
|
+
assert.deepEqual((await none.json()).pids, []);
|
|
624
|
+
} finally {
|
|
625
|
+
await instance.close();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
573
629
|
test('the agent can install a skill from chat (with approval)', async () => {
|
|
574
630
|
const calls = { n: 0 };
|
|
575
631
|
const toolProvider = {
|
|
@@ -775,6 +831,56 @@ test('lists and previews workspace files, confining to the root', async () => {
|
|
|
775
831
|
}
|
|
776
832
|
});
|
|
777
833
|
|
|
834
|
+
test('reports git status and per-file diffs', { skip: !HAS_GIT }, async () => {
|
|
835
|
+
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-git-'));
|
|
836
|
+
gitInit(root);
|
|
837
|
+
await writeFile(path.join(root, 'app.js'), 'const a = 1;\n');
|
|
838
|
+
execFileSync('git', ['add', 'app.js'], { cwd: root });
|
|
839
|
+
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: root });
|
|
840
|
+
await writeFile(path.join(root, 'app.js'), 'const a = 2;\n');
|
|
841
|
+
await writeFile(path.join(root, 'nuevo.txt'), 'hola\n');
|
|
842
|
+
|
|
843
|
+
const { instance, base } = await startServer();
|
|
844
|
+
try {
|
|
845
|
+
const status = await (
|
|
846
|
+
await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: root })}`)
|
|
847
|
+
).json();
|
|
848
|
+
assert.equal(status.isRepo, true);
|
|
849
|
+
assert.ok(status.branch);
|
|
850
|
+
assert.deepEqual(
|
|
851
|
+
status.entries.map((e) => e.path).sort(),
|
|
852
|
+
['app.js', 'nuevo.txt']
|
|
853
|
+
);
|
|
854
|
+
assert.equal(status.entries.find((e) => e.path === 'app.js').status, 'modified');
|
|
855
|
+
assert.equal(status.entries.find((e) => e.path === 'nuevo.txt').status, 'untracked');
|
|
856
|
+
|
|
857
|
+
const diff = await (
|
|
858
|
+
await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'app.js' })}`)
|
|
859
|
+
).json();
|
|
860
|
+
assert.match(diff.diff, /-const a = 1;/);
|
|
861
|
+
assert.match(diff.diff, /\+const a = 2;/);
|
|
862
|
+
|
|
863
|
+
const untracked = await (
|
|
864
|
+
await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'nuevo.txt' })}`)
|
|
865
|
+
).json();
|
|
866
|
+
assert.equal(untracked.untracked, true);
|
|
867
|
+
assert.match(untracked.diff, /\+hola/);
|
|
868
|
+
|
|
869
|
+
const outside = await fetch(
|
|
870
|
+
`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: '../fuera' })}`
|
|
871
|
+
);
|
|
872
|
+
assert.equal(outside.status, 400);
|
|
873
|
+
|
|
874
|
+
const plain = await mkdtemp(path.join(tmpdir(), 'tamnora-nogit-'));
|
|
875
|
+
const none = await (
|
|
876
|
+
await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: plain })}`)
|
|
877
|
+
).json();
|
|
878
|
+
assert.equal(none.isRepo, false);
|
|
879
|
+
} finally {
|
|
880
|
+
await instance.close();
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
|
|
778
884
|
test('previews an HTML file through a static server', async () => {
|
|
779
885
|
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-html-'));
|
|
780
886
|
await writeFile(path.join(root, 'index.html'), '<h1>hola</h1>');
|
package/package.json
CHANGED