@semalt-ai/code 1.8.5 → 1.19.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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ // Tests for the cost calculation (Task 2.6) and the /doctor check aggregation.
4
+ // Both are pure; runDoctor is exercised with mocked deps.
5
+
6
+ const { test } = require('node:test');
7
+ const assert = require('node:assert');
8
+
9
+ const { priceForModel, computeCost, formatCost } = require('../lib/pricing');
10
+ const { aggregateChecks, formatDoctorReport, runDoctor } = require('../lib/doctor');
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Pricing
14
+ // ---------------------------------------------------------------------------
15
+
16
+ test('priceForModel resolves built-in models, prefers the most specific key', () => {
17
+ assert.deepStrictEqual(priceForModel('gpt-4o'), { input: 2.5, output: 10 });
18
+ // substring + longest-key-wins: a name containing gpt-4o-mini must not match gpt-4o
19
+ assert.deepStrictEqual(priceForModel('openai/gpt-4o-mini'), { input: 0.15, output: 0.6 });
20
+ });
21
+
22
+ test('priceForModel returns null for an unknown model', () => {
23
+ assert.strictEqual(priceForModel('local-llama-3'), null);
24
+ assert.strictEqual(priceForModel(''), null);
25
+ assert.strictEqual(priceForModel(undefined), null);
26
+ });
27
+
28
+ test('priceForModel honors config overrides', () => {
29
+ const price = priceForModel('my-custom-model', { 'my-custom-model': { input: 1, output: 2 } });
30
+ assert.deepStrictEqual(price, { input: 1, output: 2 });
31
+ });
32
+
33
+ test('computeCost multiplies usage by the per-Mtok price', () => {
34
+ const price = { input: 2.5, output: 10 };
35
+ assert.strictEqual(computeCost({ prompt_tokens: 1_000_000, completion_tokens: 1_000_000 }, price), 12.5);
36
+ assert.strictEqual(computeCost({ prompt_tokens: 1000, completion_tokens: 500 }, price), 0.0075);
37
+ });
38
+
39
+ test('computeCost returns null (unknown) when the price is unknown — never a fake 0', () => {
40
+ assert.strictEqual(computeCost({ prompt_tokens: 1000, completion_tokens: 1000 }, null), null);
41
+ });
42
+
43
+ test('formatCost renders unknown vs amounts', () => {
44
+ assert.strictEqual(formatCost(null), 'unknown');
45
+ assert.strictEqual(formatCost(undefined), 'unknown');
46
+ assert.strictEqual(formatCost(0), '$0.00');
47
+ assert.strictEqual(formatCost(12.5), '$12.5000');
48
+ assert.strictEqual(formatCost(0.0075), '$0.007500');
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Doctor aggregation
53
+ // ---------------------------------------------------------------------------
54
+
55
+ test('aggregateChecks: overall is fail if any fail', () => {
56
+ const r = aggregateChecks([
57
+ { name: 'a', status: 'pass', detail: '' },
58
+ { name: 'b', status: 'warn', detail: '' },
59
+ { name: 'c', status: 'fail', detail: '' },
60
+ ]);
61
+ assert.strictEqual(r.overall, 'fail');
62
+ assert.deepStrictEqual(r.counts, { pass: 1, warn: 1, fail: 1 });
63
+ });
64
+
65
+ test('aggregateChecks: overall is warn if any warn but no fail', () => {
66
+ const r = aggregateChecks([
67
+ { name: 'a', status: 'pass', detail: '' },
68
+ { name: 'b', status: 'warn', detail: '' },
69
+ ]);
70
+ assert.strictEqual(r.overall, 'warn');
71
+ });
72
+
73
+ test('aggregateChecks: overall is pass when all pass', () => {
74
+ const r = aggregateChecks([{ name: 'a', status: 'pass', detail: '' }]);
75
+ assert.strictEqual(r.overall, 'pass');
76
+ assert.strictEqual(r.counts.pass, 1);
77
+ });
78
+
79
+ test('formatDoctorReport renders an icon per check and an overall line', () => {
80
+ const out = formatDoctorReport(aggregateChecks([
81
+ { name: 'config', status: 'pass', detail: 'ok' },
82
+ { name: 'dashboard', status: 'fail', detail: 'unreachable' },
83
+ ]));
84
+ assert.ok(out.includes('✓ config: ok'));
85
+ assert.ok(out.includes('✗ dashboard: unreachable'));
86
+ assert.ok(/Overall: FAIL/.test(out));
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // runDoctor with mocked deps
91
+ // ---------------------------------------------------------------------------
92
+
93
+ test('runDoctor builds the expected checks and overall verdict', async () => {
94
+ const result = await runDoctor({
95
+ config: { default_model: 'gpt-4o', context_length: 128000, dashboard_url: 'http://dash' },
96
+ layers: { userPresent: true, projectPath: '/repo/.semalt/config.json', envKeys: ['api_base'], flagKeys: [] },
97
+ apiKeySource: 'keychain',
98
+ memoryFiles: [{ path: '/repo/AGENTS.md' }],
99
+ auditWritable: () => true,
100
+ pingDashboard: async () => true,
101
+ });
102
+ assert.strictEqual(result.overall, 'pass');
103
+ const byName = Object.fromEntries(result.checks.map((c) => [c.name, c]));
104
+ assert.strictEqual(byName.config.status, 'pass');
105
+ assert.ok(/project\(\/repo/.test(byName.config.detail));
106
+ assert.strictEqual(byName['api key'].status, 'pass');
107
+ assert.strictEqual(byName.model.status, 'pass');
108
+ assert.strictEqual(byName.dashboard.status, 'pass');
109
+ assert.strictEqual(byName['audit log'].status, 'pass');
110
+ assert.strictEqual(byName.memory.status, 'pass');
111
+ });
112
+
113
+ test('runDoctor flags an unreachable dashboard and unwritable audit log as fail', async () => {
114
+ const result = await runDoctor({
115
+ config: { default_model: 'gpt-4o', context_length: 128000, dashboard_url: 'http://dash' },
116
+ layers: { userPresent: true },
117
+ apiKeySource: 'config',
118
+ memoryFiles: [],
119
+ auditWritable: () => false,
120
+ pingDashboard: async () => false,
121
+ });
122
+ assert.strictEqual(result.overall, 'fail');
123
+ const byName = Object.fromEntries(result.checks.map((c) => [c.name, c]));
124
+ assert.strictEqual(byName.dashboard.status, 'fail');
125
+ assert.strictEqual(byName['audit log'].status, 'fail');
126
+ });
127
+
128
+ test('runDoctor warns when not logged in (dashboard skipped) and no model selected', async () => {
129
+ const result = await runDoctor({
130
+ config: { default_model: '', dashboard_url: 'http://dash' },
131
+ layers: { userPresent: false },
132
+ apiKeySource: 'none',
133
+ memoryFiles: [],
134
+ auditWritable: () => true,
135
+ pingDashboard: async () => null,
136
+ });
137
+ assert.strictEqual(result.overall, 'warn');
138
+ const byName = Object.fromEntries(result.checks.map((c) => [c.name, c]));
139
+ assert.strictEqual(byName.dashboard.status, 'warn');
140
+ assert.strictEqual(byName.model.status, 'warn');
141
+ assert.strictEqual(byName['api key'].status, 'warn');
142
+ });
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ // End-to-end (via the chat harness): a Markdown-defined custom command, once
4
+ // discovered at chat startup, renders its template and submits the result to the
5
+ // agent as a user prompt. The harness redirects $HOME to a temp dir before any
6
+ // lib module loads, so we stage command files under that temp global dir.
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert');
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ // Requiring the harness fixes process.env.HOME to its temp dir; read it after.
14
+ const { startChat } = require('./harness/chat-harness');
15
+ const { clearCustomCommands } = require('../lib/commands/registry');
16
+
17
+ const GLOBAL_CMD_DIR = path.join(process.env.HOME, '.semalt-ai', 'commands');
18
+
19
+ function stage(name, content) {
20
+ fs.mkdirSync(GLOBAL_CMD_DIR, { recursive: true });
21
+ fs.writeFileSync(path.join(GLOBAL_CMD_DIR, name), content);
22
+ }
23
+ function clearStaged() {
24
+ try { fs.rmSync(GLOBAL_CMD_DIR, { recursive: true, force: true }); } catch {}
25
+ clearCustomCommands();
26
+ }
27
+
28
+ test('custom command is discovered at startup and announced', async () => {
29
+ clearStaged();
30
+ stage('review.md', '---\ndescription: Review code\n---\nReview $ARGUMENTS');
31
+ const c = await startChat({ config: { auth_token: 'tok' } });
32
+ try {
33
+ assert.ok(c.chatHistory.find(/Loaded 1 custom command\(s\): \/review/), 'startup announces the custom command');
34
+ } finally {
35
+ await c.submit('exit'); await c.done; c.cleanup(); clearStaged();
36
+ }
37
+ });
38
+
39
+ test('invoking a custom command submits its rendered template to the agent', async () => {
40
+ clearStaged();
41
+ stage('review.md', 'Please review $ARGUMENTS for correctness.');
42
+ const c = await startChat({ config: { auth_token: 'tok' } });
43
+ try {
44
+ await c.submit('/review src/app.js');
45
+ assert.strictEqual(c.calls.runAgentLoop.length, 1, 'agent invoked once');
46
+ const turn = c.calls.runAgentLoop[0];
47
+ const userMsgs = turn.messages.filter((m) => m.role === 'user').map((m) => m.content);
48
+ assert.deepStrictEqual(
49
+ userMsgs,
50
+ ['Please review src/app.js for correctness.'],
51
+ 'the rendered template (not the raw /review ...) reaches the agent',
52
+ );
53
+ // And it is shown in the chat history as a user message.
54
+ assert.ok(
55
+ c.chatHistory.messages.some((m) => m.role === 'user' && m.content === 'Please review src/app.js for correctness.'),
56
+ 'rendered prompt shown in history',
57
+ );
58
+ } finally {
59
+ await c.submit('exit'); await c.done; c.cleanup(); clearStaged();
60
+ }
61
+ });
62
+
63
+ test('custom command with positional args renders $1/$2', async () => {
64
+ clearStaged();
65
+ stage('greet.md', 'Say $1 to $2');
66
+ const c = await startChat({ config: { auth_token: 'tok' } });
67
+ try {
68
+ await c.submit('/greet hello world');
69
+ const turn = c.calls.runAgentLoop[0];
70
+ const userMsgs = turn.messages.filter((m) => m.role === 'user').map((m) => m.content);
71
+ assert.deepStrictEqual(userMsgs, ['Say hello to world']);
72
+ } finally {
73
+ await c.submit('exit'); await c.done; c.cleanup(); clearStaged();
74
+ }
75
+ });
76
+
77
+ test('a built-in is never overridden by a same-named custom command', async () => {
78
+ clearStaged();
79
+ stage('clear.md', 'this should never run as a prompt');
80
+ const c = await startChat({ config: { auth_token: 'tok' } });
81
+ try {
82
+ // Startup should warn that the custom /clear was shadowed by the built-in.
83
+ assert.ok(c.chatHistory.find(/\/clear.*built-in/i), 'collision warning shown');
84
+ await c.submit('a message');
85
+ const before = c.calls.runAgentLoop.length;
86
+ await c.submit('/clear');
87
+ // Built-in /clear ran (reset notice), and the custom template was NOT sent to the agent.
88
+ assert.ok(c.chatHistory.find(/cleared/i), 'built-in /clear executed');
89
+ assert.strictEqual(c.calls.runAgentLoop.length, before, 'custom /clear did not invoke the agent');
90
+ } finally {
91
+ await c.submit('exit'); await c.done; c.cleanup(); clearStaged();
92
+ }
93
+ });
94
+
95
+ test('custom command rendered prompt is blocked when not logged in (agent path)', async () => {
96
+ clearStaged();
97
+ stage('ask.md', 'Do $ARGUMENTS');
98
+ const c = await startChat({ config: { auth_token: '' } });
99
+ try {
100
+ await c.submit('/ask something');
101
+ assert.ok(c.chatHistory.find(/Not logged in/), 'rendered prompt goes through the auth-gated agent path');
102
+ assert.strictEqual(c.calls.runAgentLoop.length, 0, 'agent not invoked while unauthenticated');
103
+ } finally {
104
+ await c.submit('exit'); await c.done; c.cleanup(); clearStaged();
105
+ }
106
+ });
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ // Tests for Markdown-defined custom slash commands (Task 3.1). Covers discovery
4
+ // under temp $HOME/$cwd, frontmatter parsing, $ARGUMENTS / positional rendering,
5
+ // project-over-global precedence, repo-root-bounded upward discovery, and the
6
+ // registry registration (built-in collision handling + completion/help/resolve
7
+ // surfaces). Filesystem state is isolated to per-test temp directories.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const {
16
+ parseFrontmatter,
17
+ parseAliasList,
18
+ renderTemplate,
19
+ discoverCustomCommands,
20
+ findProjectCommandsDir,
21
+ } = require('../lib/commands/custom');
22
+ const {
23
+ registerCustomCommands,
24
+ clearCustomCommands,
25
+ resolveCommand,
26
+ completionNames,
27
+ helpText,
28
+ commandNames,
29
+ } = require('../lib/commands/registry');
30
+
31
+ function tmp(prefix) {
32
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
33
+ }
34
+ function writeCmd(dir, name, content) {
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ fs.writeFileSync(path.join(dir, name), content);
37
+ }
38
+ function rmrf(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch {} }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Frontmatter parsing
42
+ // ---------------------------------------------------------------------------
43
+
44
+ test('parseFrontmatter: no frontmatter → whole text is the body', () => {
45
+ const { meta, body } = parseFrontmatter('Just a prompt $ARGUMENTS');
46
+ assert.strictEqual(body, 'Just a prompt $ARGUMENTS');
47
+ assert.strictEqual(meta.description, '');
48
+ assert.strictEqual(meta.argumentHint, '');
49
+ assert.deepStrictEqual(meta.aliases, []);
50
+ });
51
+
52
+ test('parseFrontmatter: description, argument-hint, aliases parsed; body follows', () => {
53
+ const src = [
54
+ '---',
55
+ 'description: Review a file for bugs',
56
+ 'argument-hint: <path>',
57
+ 'aliases: [rev, cr]',
58
+ '---',
59
+ 'Review the file at $1 carefully.',
60
+ ].join('\n');
61
+ const { meta, body } = parseFrontmatter(src);
62
+ assert.strictEqual(meta.description, 'Review a file for bugs');
63
+ assert.strictEqual(meta.argumentHint, '<path>');
64
+ assert.deepStrictEqual(meta.aliases, ['rev', 'cr']);
65
+ assert.strictEqual(body, 'Review the file at $1 carefully.');
66
+ });
67
+
68
+ test('parseFrontmatter: comma-separated aliases and quoted values', () => {
69
+ const src = '---\ndescription: "quoted desc"\naliases: rev, cr\n---\nbody';
70
+ const { meta } = parseFrontmatter(src);
71
+ assert.strictEqual(meta.description, 'quoted desc');
72
+ assert.deepStrictEqual(meta.aliases, ['rev', 'cr']);
73
+ });
74
+
75
+ test('parseFrontmatter: unknown keys ignored, CRLF tolerated', () => {
76
+ const src = '---\r\ndescription: d\r\nmodel: gpt-4o\r\n---\r\nthe body\r\nline2';
77
+ const { meta, body } = parseFrontmatter(src);
78
+ assert.strictEqual(meta.description, 'd');
79
+ assert.strictEqual(body, 'the body\nline2');
80
+ });
81
+
82
+ test('parseAliasList: flow list and comma forms', () => {
83
+ assert.deepStrictEqual(parseAliasList('[a, b, c]'), ['a', 'b', 'c']);
84
+ assert.deepStrictEqual(parseAliasList('a, b'), ['a', 'b']);
85
+ assert.deepStrictEqual(parseAliasList(''), []);
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Template rendering
90
+ // ---------------------------------------------------------------------------
91
+
92
+ test('renderTemplate: $ARGUMENTS substitutes the full argument string', () => {
93
+ assert.strictEqual(renderTemplate('Fix: $ARGUMENTS', 'the login bug'), 'Fix: the login bug');
94
+ assert.strictEqual(renderTemplate('Fix: $ARGUMENTS', ''), 'Fix: ');
95
+ });
96
+
97
+ test('renderTemplate: positional $1/$2 substitution', () => {
98
+ assert.strictEqual(renderTemplate('$1 then $2', 'alpha beta'), 'alpha then beta');
99
+ });
100
+
101
+ test('renderTemplate: missing positionals render empty', () => {
102
+ assert.strictEqual(renderTemplate('[$1][$2][$3]', 'only'), '[only][][]');
103
+ });
104
+
105
+ test('renderTemplate: $ARGUMENTS injected text is not re-expanded as positionals', () => {
106
+ // args contain a literal "$1" — single-pass rendering must leave it intact.
107
+ assert.strictEqual(renderTemplate('X: $ARGUMENTS', 'a $1 b'), 'X: a $1 b');
108
+ });
109
+
110
+ test('renderTemplate: both $ARGUMENTS and positionals in one template', () => {
111
+ assert.strictEqual(renderTemplate('all=[$ARGUMENTS] first=$1', 'a b c'), 'all=[a b c] first=a');
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Discovery (global / project / precedence / repo-root bound)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ test('discoverCustomCommands: global commands from ~/.semalt-ai/commands', () => {
119
+ const home = tmp('semalt-home-');
120
+ try {
121
+ writeCmd(path.join(home, '.semalt-ai', 'commands'), 'review.md', 'Review $ARGUMENTS');
122
+ const cmds = discoverCustomCommands({ home, cwd: home });
123
+ assert.strictEqual(cmds.length, 1);
124
+ assert.strictEqual(cmds[0].name, '/review');
125
+ assert.strictEqual(cmds[0].source, 'global');
126
+ assert.strictEqual(cmds[0].template, 'Review $ARGUMENTS');
127
+ } finally { rmrf(home); }
128
+ });
129
+
130
+ test('discoverCustomCommands: project commands from nearest .semalt/commands', () => {
131
+ const home = tmp('semalt-home-');
132
+ const repo = tmp('semalt-repo-');
133
+ try {
134
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
135
+ const sub = path.join(repo, 'src', 'deep');
136
+ fs.mkdirSync(sub, { recursive: true });
137
+ writeCmd(path.join(repo, '.semalt', 'commands'), 'deploy.md', 'Deploy now');
138
+ const cmds = discoverCustomCommands({ home, cwd: sub });
139
+ assert.strictEqual(cmds.length, 1);
140
+ assert.strictEqual(cmds[0].name, '/deploy');
141
+ assert.strictEqual(cmds[0].source, 'project');
142
+ } finally { rmrf(home); rmrf(repo); }
143
+ });
144
+
145
+ test('discoverCustomCommands: project overrides global on name collision', () => {
146
+ const home = tmp('semalt-home-');
147
+ const repo = tmp('semalt-repo-');
148
+ try {
149
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
150
+ writeCmd(path.join(home, '.semalt-ai', 'commands'), 'review.md', 'GLOBAL review');
151
+ writeCmd(path.join(repo, '.semalt', 'commands'), 'review.md', 'PROJECT review');
152
+ writeCmd(path.join(home, '.semalt-ai', 'commands'), 'onlyglobal.md', 'global only');
153
+ const cmds = discoverCustomCommands({ home, cwd: repo });
154
+ const review = cmds.find((c) => c.name === '/review');
155
+ assert.strictEqual(review.template, 'PROJECT review');
156
+ assert.strictEqual(review.source, 'project');
157
+ // The global-only command still surfaces.
158
+ assert.ok(cmds.find((c) => c.name === '/onlyglobal'));
159
+ } finally { rmrf(home); rmrf(repo); }
160
+ });
161
+
162
+ test('findProjectCommandsDir: bounded by repo root — does not escape above .git', () => {
163
+ const outer = tmp('semalt-outer-');
164
+ try {
165
+ // .semalt/commands lives ABOVE the repo root; discovery must not reach it.
166
+ writeCmd(path.join(outer, '.semalt', 'commands'), 'x.md', 'nope');
167
+ const repo = path.join(outer, 'repo');
168
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
169
+ const sub = path.join(repo, 'a', 'b');
170
+ fs.mkdirSync(sub, { recursive: true });
171
+ assert.strictEqual(findProjectCommandsDir(sub), null);
172
+ } finally { rmrf(outer); }
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Registry registration
177
+ // ---------------------------------------------------------------------------
178
+
179
+ test('registerCustomCommands: custom resolves and completes; built-ins win on collision', () => {
180
+ clearCustomCommands();
181
+ try {
182
+ const { registered, warnings } = registerCustomCommands([
183
+ { name: '/review', template: 'Review $ARGUMENTS', description: 'Do a review', argumentHint: '<path>', source: 'global' },
184
+ { name: '/model', template: 'shadow attempt', source: 'global' }, // collides with built-in
185
+ ]);
186
+ assert.strictEqual(registered.length, 1, 'only the non-colliding custom registers');
187
+ assert.strictEqual(registered[0].name, '/review');
188
+ assert.strictEqual(warnings.length, 1, 'collision produced a warning');
189
+ assert.match(warnings[0], /\/model/);
190
+
191
+ // /model still resolves to the built-in, not the custom.
192
+ const m = resolveCommand('/model');
193
+ assert.strictEqual(m.name, '/model');
194
+ assert.ok(!m.spec.custom, 'built-in /model not shadowed by custom');
195
+
196
+ // /review resolves to the custom with its arg + template carried on the spec.
197
+ const r = resolveCommand('/review src/app.js');
198
+ assert.strictEqual(r.name, '/review');
199
+ assert.strictEqual(r.arg, 'src/app.js');
200
+ assert.ok(r.spec.custom, 'custom flagged on the spec');
201
+ assert.strictEqual(r.spec.template, 'Review $ARGUMENTS');
202
+
203
+ // Bare invocation (no arg) also resolves (optional-arg behavior).
204
+ assert.strictEqual(resolveCommand('/review').name, '/review');
205
+
206
+ // Completion + help surface the custom command.
207
+ assert.ok(completionNames().includes('/review'));
208
+ assert.match(helpText(), /Custom commands:/);
209
+ assert.match(helpText(), /\/review <path> Do a review/);
210
+
211
+ // The parity-check name list stays built-ins only (no custom handler needed).
212
+ assert.ok(!commandNames().includes('/review'));
213
+ } finally { clearCustomCommands(); }
214
+ });
215
+
216
+ test('registerCustomCommands replaces the prior set (idempotent re-registration)', () => {
217
+ clearCustomCommands();
218
+ try {
219
+ registerCustomCommands([{ name: '/one', template: 'a' }]);
220
+ assert.ok(resolveCommand('/one'));
221
+ registerCustomCommands([{ name: '/two', template: 'b' }]);
222
+ assert.strictEqual(resolveCommand('/one'), null, 'prior custom dropped on re-register');
223
+ assert.ok(resolveCommand('/two'));
224
+ } finally { clearCustomCommands(); }
225
+ });
226
+
227
+ test('helpText is unchanged when no custom commands are registered', () => {
228
+ clearCustomCommands();
229
+ assert.ok(!/Custom commands:/.test(helpText()));
230
+ });
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ // Unit tests for the Windows (cmd.exe / PowerShell) destructive deny-list set
4
+ // and the procfs-root canonicalization added in Task 4.4. These run on ANY
5
+ // platform — the deny-list is pattern-based, so the Windows coverage is testable
6
+ // without Windows.
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert');
10
+
11
+ const { checkShellDenylist, classifyShellCommand } = require('../lib/deny');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Windows recursive delete
15
+ // ---------------------------------------------------------------------------
16
+
17
+ test('Windows recursive delete (del /s, rd /s, rmdir /s) is denied', () => {
18
+ const cases = [
19
+ 'del /s /q C:\\Users\\me\\project',
20
+ 'del /q /s data',
21
+ 'del /f /s /q *.*',
22
+ 'rd /s /q C:\\temp',
23
+ 'rmdir /s /q build',
24
+ 'RD /S C:\\Windows\\Temp',
25
+ ];
26
+ for (const cmd of cases) {
27
+ const r = checkShellDenylist(cmd);
28
+ assert.ok(r, `${cmd} should be denied`);
29
+ assert.match(r.label, /Windows recursive delete/);
30
+ }
31
+ });
32
+
33
+ test('plain del / rd without /s are allowed', () => {
34
+ for (const cmd of ['del stale.log', 'del /q one.txt', 'rd emptydir', 'rmdir olddir']) {
35
+ assert.strictEqual(checkShellDenylist(cmd), null, `${cmd} should be allowed`);
36
+ }
37
+ });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // PowerShell recursive force delete
41
+ // ---------------------------------------------------------------------------
42
+
43
+ test('PowerShell Remove-Item -Recurse -Force is denied', () => {
44
+ const cases = [
45
+ 'Remove-Item -Recurse -Force C:\\data',
46
+ 'Remove-Item -Force -Recurse .\\node_modules',
47
+ 'Remove-Item -Recurse -Force -Path C:\\x',
48
+ ];
49
+ for (const cmd of cases) {
50
+ const r = checkShellDenylist(cmd);
51
+ assert.ok(r, `${cmd} should be denied`);
52
+ assert.match(r.label, /PowerShell recursive force delete/);
53
+ }
54
+ });
55
+
56
+ test('Remove-Item without BOTH -Recurse and -Force is allowed', () => {
57
+ for (const cmd of ['Remove-Item one.txt', 'Remove-Item -Recurse logs', 'Remove-Item -Force single.tmp']) {
58
+ assert.strictEqual(checkShellDenylist(cmd), null, `${cmd} should be allowed`);
59
+ }
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Windows format / disk wipe (catastrophic)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ test('Windows format / disk-wipe set is denied AND flagged catastrophic', () => {
67
+ const cases = [
68
+ 'format C: /fs:ntfs',
69
+ 'format D:',
70
+ 'Format-Volume -DriveLetter D',
71
+ 'Clear-Disk -Number 0 -RemoveData',
72
+ 'cipher /w:C',
73
+ 'diskpart /s script.txt clean',
74
+ ];
75
+ for (const cmd of cases) {
76
+ const r = checkShellDenylist(cmd);
77
+ assert.ok(r, `${cmd} should be denied`);
78
+ assert.strictEqual(r.catastrophic, true, `${cmd} should be catastrophic`);
79
+ }
80
+ });
81
+
82
+ test('benign uses of similar words are not caught', () => {
83
+ for (const cmd of ['git format-patch -1', 'npm run format', 'echo format the report']) {
84
+ assert.strictEqual(checkShellDenylist(cmd), null, `${cmd} should be allowed`);
85
+ }
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // classifyShellCommand integration — Windows catastrophic gets the typo guard
90
+ // ---------------------------------------------------------------------------
91
+
92
+ test('user-initiated Windows format asks for confirmation (catastrophic typo guard)', () => {
93
+ assert.strictEqual(classifyShellCommand('format C:', 'user').action, 'confirm');
94
+ // del /s is destructive but not catastrophic → user keeps the bypass.
95
+ assert.strictEqual(classifyShellCommand('del /s /q C:\\x', 'user').action, 'allow');
96
+ // agent-initiated → hard block for both.
97
+ assert.strictEqual(classifyShellCommand('format C:', 'agent').action, 'block');
98
+ assert.strictEqual(classifyShellCommand('del /s /q C:\\x', 'agent').action, 'block');
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // procfs-root canonicalization (constraint #3)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ test('/proc/self/root path-rewrite is canonicalized so /etc matchers still fire', () => {
106
+ // The textual path dodges a naive /etc matcher; canonicalization rewrites the
107
+ // procfs-root prefix back to / so the existing system-path rule catches it.
108
+ const r = checkShellDenylist('echo pwned > /proc/self/root/etc/passwd');
109
+ assert.ok(r, 'write via /proc/self/root/etc must be denied');
110
+ assert.match(r.label, /system/i);
111
+ });
112
+
113
+ test('/proc/<pid>/root rewrite is canonicalized too', () => {
114
+ const r = checkShellDenylist('tee /proc/1234/root/etc/cron.d/x');
115
+ assert.ok(r, 'write via /proc/<pid>/root/etc must be denied');
116
+ });
117
+
118
+ test('a benign /proc read is still allowed', () => {
119
+ assert.strictEqual(checkShellDenylist('cat /proc/self/status'), null);
120
+ });