@hasna/terminal 4.3.1 → 4.3.2

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 (79) hide show
  1. package/dist/App.js +404 -0
  2. package/dist/Browse.js +79 -0
  3. package/dist/FuzzyPicker.js +47 -0
  4. package/dist/Onboarding.js +51 -0
  5. package/dist/Spinner.js +12 -0
  6. package/dist/StatusBar.js +49 -0
  7. package/dist/ai.js +316 -0
  8. package/dist/cache.js +42 -0
  9. package/dist/cli.js +778 -0
  10. package/dist/command-rewriter.js +64 -0
  11. package/dist/command-validator.js +86 -0
  12. package/dist/compression.js +91 -0
  13. package/dist/context-hints.js +285 -0
  14. package/dist/diff-cache.js +107 -0
  15. package/dist/discover.js +212 -0
  16. package/dist/economy.js +155 -0
  17. package/dist/expand-store.js +44 -0
  18. package/dist/file-cache.js +72 -0
  19. package/dist/file-index.js +62 -0
  20. package/dist/history.js +62 -0
  21. package/dist/lazy-executor.js +54 -0
  22. package/dist/line-dedup.js +59 -0
  23. package/dist/loop-detector.js +75 -0
  24. package/dist/mcp/install.js +189 -0
  25. package/dist/mcp/server.js +56 -0
  26. package/dist/mcp/tools/batch.js +111 -0
  27. package/dist/mcp/tools/execute.js +194 -0
  28. package/dist/mcp/tools/files.js +290 -0
  29. package/dist/mcp/tools/git.js +233 -0
  30. package/dist/mcp/tools/helpers.js +63 -0
  31. package/dist/mcp/tools/memory.js +151 -0
  32. package/dist/mcp/tools/meta.js +138 -0
  33. package/dist/mcp/tools/process.js +50 -0
  34. package/dist/mcp/tools/project.js +251 -0
  35. package/dist/mcp/tools/search.js +86 -0
  36. package/dist/noise-filter.js +94 -0
  37. package/dist/output-processor.js +233 -0
  38. package/dist/output-store.js +112 -0
  39. package/dist/paths.js +28 -0
  40. package/dist/providers/anthropic.js +43 -0
  41. package/dist/providers/base.js +4 -0
  42. package/dist/providers/cerebras.js +8 -0
  43. package/dist/providers/groq.js +8 -0
  44. package/dist/providers/index.js +142 -0
  45. package/dist/providers/openai-compat.js +93 -0
  46. package/dist/providers/xai.js +8 -0
  47. package/dist/recipes/model.js +20 -0
  48. package/dist/recipes/storage.js +153 -0
  49. package/dist/search/content-search.js +70 -0
  50. package/dist/search/file-search.js +61 -0
  51. package/dist/search/filters.js +34 -0
  52. package/dist/search/index.js +5 -0
  53. package/dist/search/semantic.js +346 -0
  54. package/dist/session-boot.js +59 -0
  55. package/dist/session-context.js +55 -0
  56. package/dist/sessions-db.js +240 -0
  57. package/dist/smart-display.js +286 -0
  58. package/dist/snapshots.js +51 -0
  59. package/dist/supervisor.js +112 -0
  60. package/dist/test-watchlist.js +131 -0
  61. package/dist/tokens.js +17 -0
  62. package/dist/tool-profiles.js +130 -0
  63. package/dist/tree.js +94 -0
  64. package/dist/usage-cache.js +65 -0
  65. package/package.json +2 -1
  66. package/src/Onboarding.tsx +1 -1
  67. package/src/ai.ts +5 -4
  68. package/src/cache.ts +2 -2
  69. package/src/economy.ts +3 -3
  70. package/src/history.ts +2 -2
  71. package/src/mcp/server.ts +2 -0
  72. package/src/mcp/tools/memory.ts +4 -2
  73. package/src/output-store.ts +2 -1
  74. package/src/paths.ts +32 -0
  75. package/src/recipes/storage.ts +3 -3
  76. package/src/session-context.ts +2 -2
  77. package/src/sessions-db.ts +15 -4
  78. package/src/tool-profiles.ts +4 -3
  79. package/src/usage-cache.ts +2 -2
@@ -0,0 +1,131 @@
1
+ // Test focus tracker — tracks test status across runs, only reports changes
2
+ // Instead of showing "248 passed, 2 failed" every time, shows:
3
+ // "auth.login: FIXED, auth.logout: STILL FAILING, 246 unchanged"
4
+ // Per-cwd watchlist
5
+ const watchlists = new Map();
6
+ /** Extract test names and status from test runner output (any runner) */
7
+ function extractTests(output) {
8
+ const tests = [];
9
+ const lines = output.split("\n");
10
+ for (let i = 0; i < lines.length; i++) {
11
+ const line = lines[i];
12
+ // PASS/FAIL with test name: "PASS src/auth.test.ts" or "✓ login works" or "✗ logout fails"
13
+ const passMatch = line.match(/(?:PASS|✓|✔|✅)\s+(.+)/);
14
+ if (passMatch) {
15
+ tests.push({ name: passMatch[1].trim(), status: "pass" });
16
+ continue;
17
+ }
18
+ const failMatch = line.match(/(?:FAIL|✗|✕|❌|×)\s+(.+)/);
19
+ if (failMatch) {
20
+ // Capture error from next few lines
21
+ const errorLines = [];
22
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
23
+ if (lines[j].match(/(?:PASS|FAIL|✓|✗|✔|✕|Tests:|^\s*$)/))
24
+ break;
25
+ errorLines.push(lines[j].trim());
26
+ }
27
+ tests.push({ name: failMatch[1].trim(), status: "fail", error: errorLines.join(" ").slice(0, 200) });
28
+ continue;
29
+ }
30
+ // Jest/vitest style: " ● test name" for failures
31
+ const jestFail = line.match(/^\s*●\s+(.+)/);
32
+ if (jestFail) {
33
+ tests.push({ name: jestFail[1].trim(), status: "fail" });
34
+ continue;
35
+ }
36
+ }
37
+ return tests;
38
+ }
39
+ /** Detect if output looks like test runner output */
40
+ export function isTestOutput(output, command) {
41
+ // If the command is explicitly a test command, trust it
42
+ if (command && /\b(bun\s+test|npm\s+test|jest|vitest|pytest|cargo\s+test|go\s+test)\b/.test(command))
43
+ return true;
44
+ // Otherwise require BOTH a summary line AND a test runner marker in the output
45
+ const summaryLine = /(?:\d+\s+pass|\d+\s+fail|Tests?:\s+\d+|Ran\s+\d+\s+tests?)\s*$/im;
46
+ const testMarkers = /(?:✓|✗|✔|✕|PASS\s+\S+\.test|FAIL\s+\S+\.test|bun test v|jest|vitest|pytest)/;
47
+ return summaryLine.test(output) && testMarkers.test(output);
48
+ }
49
+ /** Track test results and return only changes */
50
+ export function trackTests(cwd, output) {
51
+ const current = extractTests(output);
52
+ const prev = watchlists.get(cwd);
53
+ // Count totals from raw output (more reliable than extracted tests)
54
+ let totalPassed = 0, totalFailed = 0;
55
+ const summaryMatch = output.match(/(\d+)\s+pass/i);
56
+ const failMatch = output.match(/(\d+)\s+fail/i);
57
+ if (summaryMatch)
58
+ totalPassed = parseInt(summaryMatch[1]);
59
+ if (failMatch)
60
+ totalFailed = parseInt(failMatch[1]);
61
+ // Fallback to extracted counts
62
+ if (totalPassed === 0)
63
+ totalPassed = current.filter(t => t.status === "pass").length;
64
+ if (totalFailed === 0)
65
+ totalFailed = current.filter(t => t.status === "fail").length;
66
+ // Store current for next comparison
67
+ const currentMap = new Map();
68
+ for (const t of current)
69
+ currentMap.set(t.name, t);
70
+ watchlists.set(cwd, currentMap);
71
+ // First run — no comparison possible
72
+ if (!prev) {
73
+ return {
74
+ changed: [],
75
+ newTests: current.filter(t => t.status === "fail"), // only show failures on first run
76
+ totalPassed,
77
+ totalFailed,
78
+ unchangedCount: 0,
79
+ firstRun: true,
80
+ };
81
+ }
82
+ // Compare with previous
83
+ const changed = [];
84
+ const newTests = [];
85
+ let unchangedCount = 0;
86
+ for (const [name, test] of currentMap) {
87
+ const prevTest = prev.get(name);
88
+ if (!prevTest) {
89
+ newTests.push(test);
90
+ }
91
+ else if (prevTest.status !== test.status) {
92
+ changed.push({ name, from: prevTest.status, to: test.status, error: test.error });
93
+ }
94
+ else {
95
+ unchangedCount++;
96
+ }
97
+ }
98
+ return { changed, newTests, totalPassed, totalFailed, unchangedCount, firstRun: false };
99
+ }
100
+ /** Format watchlist result for display */
101
+ export function formatWatchResult(result) {
102
+ const lines = [];
103
+ if (result.firstRun) {
104
+ lines.push(`${result.totalPassed} passed, ${result.totalFailed} failed`);
105
+ if (result.newTests.length > 0) {
106
+ for (const t of result.newTests) {
107
+ lines.push(` ✗ ${t.name}${t.error ? `: ${t.error}` : ""}`);
108
+ }
109
+ }
110
+ return lines.join("\n");
111
+ }
112
+ // Status changes
113
+ for (const c of result.changed) {
114
+ if (c.to === "pass")
115
+ lines.push(` ✓ FIXED: ${c.name}`);
116
+ else
117
+ lines.push(` ✗ BROKE: ${c.name}${c.error ? ` — ${c.error}` : ""}`);
118
+ }
119
+ // New failures
120
+ for (const t of result.newTests.filter(t => t.status === "fail")) {
121
+ lines.push(` ✗ NEW FAIL: ${t.name}${t.error ? ` — ${t.error}` : ""}`);
122
+ }
123
+ // Summary
124
+ if (result.changed.length === 0 && result.newTests.filter(t => t.status === "fail").length === 0) {
125
+ lines.push(`✓ ${result.totalPassed} passed, ${result.totalFailed} failed (no changes)`);
126
+ }
127
+ else {
128
+ lines.push(`${result.totalPassed} passed, ${result.totalFailed} failed, ${result.unchangedCount} unchanged`);
129
+ }
130
+ return lines.join("\n");
131
+ }
package/dist/tokens.js ADDED
@@ -0,0 +1,17 @@
1
+ // Token estimation utility — shared across all modules
2
+ // Uses content-aware heuristic: code/JSON averages ~3.3 chars/token,
3
+ // English prose averages ~4.2 chars/token.
4
+ /** Detect if content is primarily code/JSON vs English prose */
5
+ function isCodeLike(text) {
6
+ // Count structural characters common in code/JSON
7
+ const structural = (text.match(/[{}[\]();:=<>,"'`|&\\/@#$%^*+~!?]/g) || []).length;
8
+ const ratio = structural / Math.max(text.length, 1);
9
+ return ratio > 0.08; // >8% structural chars = code-like
10
+ }
11
+ /** Estimate token count for a string with content-aware heuristic */
12
+ export function estimateTokens(text) {
13
+ if (!text)
14
+ return 0;
15
+ const charsPerToken = isCodeLike(text) ? 3.3 : 4.2;
16
+ return Math.ceil(text.length / charsPerToken);
17
+ }
@@ -0,0 +1,130 @@
1
+ // Tool profiles — config-driven AI enhancement for specific command categories
2
+ // Profiles are loaded from ~/.hasna/terminal/profiles/ (user-customizable)
3
+ // Each profile tells the AI how to handle a specific tool's output
4
+ import { existsSync, readFileSync, readdirSync } from "fs";
5
+ import { join } from "path";
6
+ import { getTerminalDir } from "./paths.js";
7
+ const PROFILES_DIR = join(getTerminalDir(), "profiles");
8
+ /** Built-in profiles — sensible defaults, user can override */
9
+ const BUILTIN_PROFILES = [
10
+ {
11
+ name: "git",
12
+ detect: "^git\\b",
13
+ hints: {
14
+ compress: "For git output: show branch, file counts, insertions/deletions summary. Collapse individual diffs to file-level stats.",
15
+ errors: "Git errors often include a suggested fix (e.g., 'did you mean X?'). Extract the suggestion.",
16
+ success: "Clean working tree, successful push/pull, merge complete.",
17
+ },
18
+ output: { preservePatterns: ["conflict", "CONFLICT", "fatal", "error", "diverged"] },
19
+ },
20
+ {
21
+ name: "test",
22
+ detect: "\\b(bun|npm|yarn|pnpm)\\s+(test|run\\s+test)|\\bpytest\\b|\\bcargo\\s+test\\b|\\bgo\\s+test\\b",
23
+ hints: {
24
+ compress: "For test output: show pass/fail counts FIRST, then list ONLY failing test names with error snippets. Skip passing tests entirely.",
25
+ errors: "Test failures have: test name, expected vs actual, stack trace. Extract all three.",
26
+ success: "All tests passing = one line: '✓ N tests pass, 0 fail'",
27
+ },
28
+ output: { preservePatterns: ["FAIL", "fail", "Error", "✗", "expected", "received"] },
29
+ },
30
+ {
31
+ name: "build",
32
+ detect: "\\b(tsc|bun\\s+run\\s+build|npm\\s+run\\s+build|cargo\\s+build|go\\s+build|make)\\b",
33
+ hints: {
34
+ compress: "For build output: if success with no errors, say '✓ Build succeeded'. If errors, list each error with file:line and message.",
35
+ errors: "Build errors have file:line:column format. Group by file.",
36
+ success: "Empty output or exit 0 = build succeeded.",
37
+ },
38
+ },
39
+ {
40
+ name: "lint",
41
+ detect: "\\b(eslint|biome|ruff|clippy|golangci-lint|prettier|tsc\\s+--noEmit)\\b",
42
+ hints: {
43
+ compress: "For lint output: group violations by rule name, show count per rule, one example per rule. Skip clean files.",
44
+ errors: "Lint violations: file:line rule-name message. Group by rule.",
45
+ },
46
+ output: { maxLines: 100 },
47
+ },
48
+ {
49
+ name: "install",
50
+ detect: "\\b(npm\\s+install|bun\\s+install|yarn|pip\\s+install|cargo\\s+build|go\\s+mod)\\b",
51
+ hints: {
52
+ compress: "For install output: show only errors and final summary (packages added/removed/updated). Strip progress bars, funding notices, deprecation warnings.",
53
+ },
54
+ output: { stripPatterns: ["npm warn", "packages are looking for funding", "run `npm fund`"] },
55
+ },
56
+ {
57
+ name: "find",
58
+ detect: "^find\\b",
59
+ hints: {
60
+ compress: "For find output: if >50 results, group by top-level directory with counts. Show first 10 results as examples.",
61
+ },
62
+ },
63
+ {
64
+ name: "docker",
65
+ detect: "\\b(docker|kubectl|helm)\\b",
66
+ hints: {
67
+ compress: "For container output: show container status, image, ports. Strip pull progress and layer hashes.",
68
+ errors: "Docker errors: extract the error message after 'Error response from daemon:'",
69
+ },
70
+ },
71
+ ];
72
+ /** Load user profiles from ~/.hasna/terminal/profiles/ */
73
+ function loadUserProfiles() {
74
+ if (!existsSync(PROFILES_DIR))
75
+ return [];
76
+ const profiles = [];
77
+ try {
78
+ for (const file of readdirSync(PROFILES_DIR)) {
79
+ if (!file.endsWith(".json"))
80
+ continue;
81
+ try {
82
+ const content = JSON.parse(readFileSync(join(PROFILES_DIR, file), "utf8"));
83
+ if (content.name && content.detect)
84
+ profiles.push(content);
85
+ }
86
+ catch { }
87
+ }
88
+ }
89
+ catch { }
90
+ return profiles;
91
+ }
92
+ /** Get all profiles — user profiles override builtins by name (cached 30s) */
93
+ let _cachedProfiles = null;
94
+ let _cachedProfilesAt = 0;
95
+ export function getProfiles() {
96
+ const now = Date.now();
97
+ if (_cachedProfiles && now - _cachedProfilesAt < 30_000)
98
+ return _cachedProfiles;
99
+ const user = loadUserProfiles();
100
+ const userNames = new Set(user.map(p => p.name));
101
+ const builtins = BUILTIN_PROFILES.filter(p => !userNames.has(p.name));
102
+ _cachedProfiles = [...user, ...builtins];
103
+ _cachedProfilesAt = now;
104
+ return _cachedProfiles;
105
+ }
106
+ /** Find the matching profile for a command */
107
+ export function matchProfile(command) {
108
+ for (const profile of getProfiles()) {
109
+ try {
110
+ if (new RegExp(profile.detect).test(command))
111
+ return profile;
112
+ }
113
+ catch { }
114
+ }
115
+ return null;
116
+ }
117
+ /** Format profile hints for injection into AI prompt */
118
+ export function formatProfileHints(command) {
119
+ const profile = matchProfile(command);
120
+ if (!profile)
121
+ return "";
122
+ const lines = [`TOOL PROFILE (${profile.name}):`];
123
+ if (profile.hints.compress)
124
+ lines.push(` Compression: ${profile.hints.compress}`);
125
+ if (profile.hints.errors)
126
+ lines.push(` Errors: ${profile.hints.errors}`);
127
+ if (profile.hints.success)
128
+ lines.push(` Success: ${profile.hints.success}`);
129
+ return lines.join("\n");
130
+ }
package/dist/tree.js ADDED
@@ -0,0 +1,94 @@
1
+ // Tree compression — convert flat file paths to compact tree representation
2
+ import { readdirSync, statSync } from "fs";
3
+ import { join, basename } from "path";
4
+ import { DEFAULT_EXCLUDE_DIRS } from "./search/filters.js";
5
+ /** Build a tree from a directory */
6
+ export function buildTree(dirPath, options = {}) {
7
+ const { maxDepth = 2, includeHidden = false, depth = 0 } = options;
8
+ const name = basename(dirPath) || dirPath;
9
+ const node = { name, type: "dir", children: [], fileCount: 0 };
10
+ if (depth >= maxDepth) {
11
+ // Count files without listing them
12
+ try {
13
+ const entries = readdirSync(dirPath);
14
+ node.fileCount = entries.length;
15
+ node.children = undefined; // don't expand
16
+ }
17
+ catch {
18
+ node.fileCount = 0;
19
+ }
20
+ return node;
21
+ }
22
+ try {
23
+ const entries = readdirSync(dirPath);
24
+ for (const entry of entries) {
25
+ if (!includeHidden && entry.startsWith("."))
26
+ continue;
27
+ if (DEFAULT_EXCLUDE_DIRS.includes(entry)) {
28
+ // Show as collapsed with count
29
+ try {
30
+ const subPath = join(dirPath, entry);
31
+ const subStat = statSync(subPath);
32
+ if (subStat.isDirectory()) {
33
+ node.children.push({ name: entry, type: "dir", fileCount: -1 }); // -1 = hidden
34
+ continue;
35
+ }
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ const fullPath = join(dirPath, entry);
42
+ try {
43
+ const stat = statSync(fullPath);
44
+ if (stat.isDirectory()) {
45
+ node.children.push(buildTree(fullPath, { maxDepth, includeHidden, depth: depth + 1 }));
46
+ }
47
+ else {
48
+ node.children.push({ name: entry, type: "file", size: stat.size });
49
+ node.fileCount++;
50
+ }
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ }
56
+ }
57
+ catch { }
58
+ return node;
59
+ }
60
+ /** Render tree as compact string (for agents — minimum tokens) */
61
+ export function compactTree(node, indent = 0) {
62
+ const pad = " ".repeat(indent);
63
+ if (node.type === "file")
64
+ return `${pad}${node.name}`;
65
+ if (node.fileCount === -1)
66
+ return `${pad}${node.name}/ (hidden)`;
67
+ if (!node.children || node.children.length === 0)
68
+ return `${pad}${node.name}/ (empty)`;
69
+ if (!node.children.some(c => c.children)) {
70
+ // Leaf directory — compact single line
71
+ const files = node.children.filter(c => c.type === "file").map(c => c.name);
72
+ const dirs = node.children.filter(c => c.type === "dir");
73
+ const parts = [];
74
+ if (files.length <= 5) {
75
+ parts.push(...files);
76
+ }
77
+ else {
78
+ parts.push(`${files.length} files`);
79
+ }
80
+ for (const d of dirs) {
81
+ parts.push(`${d.name}/${d.fileCount != null ? ` (${d.fileCount === -1 ? "hidden" : d.fileCount + " files"})` : ""}`);
82
+ }
83
+ return `${pad}${node.name}/ [${parts.join(", ")}]`;
84
+ }
85
+ const lines = [`${pad}${node.name}/`];
86
+ for (const child of node.children) {
87
+ lines.push(compactTree(child, indent + 1));
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+ /** Render tree as JSON (for MCP) */
92
+ export function treeToJson(node) {
93
+ return node;
94
+ }
@@ -0,0 +1,65 @@
1
+ // Usage learning cache — zero-cost repeated queries
2
+ // After 3 identical prompt→command mappings, cache locally
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+ import { createHash } from "crypto";
6
+ import { getTerminalDir } from "./paths.js";
7
+ const DIR = getTerminalDir();
8
+ const CACHE_FILE = join(DIR, "learned.json");
9
+ function ensureDir() {
10
+ if (!existsSync(DIR))
11
+ mkdirSync(DIR, { recursive: true });
12
+ }
13
+ function hash(s) {
14
+ return createHash("md5").update(s).digest("hex").slice(0, 12);
15
+ }
16
+ function cacheKey(prompt) {
17
+ const projectHash = hash(process.cwd());
18
+ const promptHash = hash(prompt.toLowerCase().trim());
19
+ return `${projectHash}:${promptHash}`;
20
+ }
21
+ function loadCache() {
22
+ ensureDir();
23
+ if (!existsSync(CACHE_FILE))
24
+ return {};
25
+ try {
26
+ return JSON.parse(readFileSync(CACHE_FILE, "utf8"));
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ function saveCache(cache) {
33
+ ensureDir();
34
+ writeFileSync(CACHE_FILE, JSON.stringify(cache));
35
+ }
36
+ /** Check if we have a learned command for this prompt (3+ identical mappings) */
37
+ export function getLearned(prompt) {
38
+ const key = cacheKey(prompt);
39
+ const cache = loadCache();
40
+ const entry = cache[key];
41
+ if (entry && entry.count >= 3)
42
+ return entry.command;
43
+ return null;
44
+ }
45
+ /** Record a prompt→command mapping */
46
+ export function recordMapping(prompt, command) {
47
+ const key = cacheKey(prompt);
48
+ const cache = loadCache();
49
+ const existing = cache[key];
50
+ if (existing && existing.command === command) {
51
+ existing.count++;
52
+ existing.lastUsed = Date.now();
53
+ }
54
+ else {
55
+ cache[key] = { command, count: 1, lastUsed: Date.now() };
56
+ }
57
+ saveCache(cache);
58
+ }
59
+ /** Get cache stats */
60
+ export function learnedStats() {
61
+ const cache = loadCache();
62
+ const entries = Object.keys(cache).length;
63
+ const cached = Object.values(cache).filter(e => e.count >= 3).length;
64
+ return { entries, cached };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "4.3.1",
3
+ "version": "4.3.2",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "files": [
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@anthropic-ai/sdk": "^0.39.0",
25
+ "@hasna/cloud": "^0.1.0",
25
26
  "@hasna/mementos": "^0.10.0",
26
27
  "@modelcontextprotocol/sdk": "^1.27.1",
27
28
  "@typescript/vfs": "^1.6.4",
@@ -76,7 +76,7 @@ export default function Onboarding({ onDone }: Props) {
76
76
  })}
77
77
  </Box>
78
78
  <Box marginTop={1}><Text dimColor>space toggle · enter confirm</Text></Box>
79
- <Text dimColor>edit later: ~/.terminal/config.json</Text>
79
+ <Text dimColor>edit later: ~/.hasna/terminal/config.json</Text>
80
80
  </Box>
81
81
  );
82
82
  }
package/src/ai.ts CHANGED
@@ -3,10 +3,11 @@ import { cacheGet, cacheSet } from "./cache.js";
3
3
  import { getProvider } from "./providers/index.js";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
+ import { getTerminalDir } from "./paths.js";
6
7
  import { discoverProjectHints, discoverSafetyHints, formatHints } from "./context-hints.js";
7
8
 
8
9
  // ── model routing ─────────────────────────────────────────────────────────────
9
- // Config-driven model selection. Defaults per provider, user can override in ~/.terminal/config.json
10
+ // Config-driven model selection. Defaults per provider, user can override in ~/.hasna/terminal/config.json
10
11
 
11
12
  const COMPLEX_SIGNALS = [
12
13
  /\b(undo|revert|rollback|previous|last)\b/i,
@@ -18,7 +19,7 @@ const COMPLEX_SIGNALS = [
18
19
  /[|&;]{2}/,
19
20
  ];
20
21
 
21
- /** Default models per provider — user can override in ~/.terminal/config.json under "models" */
22
+ /** Default models per provider — user can override in ~/.hasna/terminal/config.json under "models" */
22
23
  const MODEL_DEFAULTS: Record<string, { fast: string; smart: string }> = {
23
24
  cerebras: { fast: "qwen-3-235b-a22b-instruct-2507", smart: "qwen-3-235b-a22b-instruct-2507" },
24
25
  groq: { fast: "openai/gpt-oss-120b", smart: "moonshotai/kimi-k2-instruct" },
@@ -26,7 +27,7 @@ const MODEL_DEFAULTS: Record<string, { fast: string; smart: string }> = {
26
27
  anthropic: { fast: "claude-haiku-4-5-20251001", smart: "claude-sonnet-4-6" },
27
28
  };
28
29
 
29
- /** Load user model overrides from ~/.terminal/config.json (cached 30s) */
30
+ /** Load user model overrides from ~/.hasna/terminal/config.json (cached 30s) */
30
31
  let _modelOverrides: Record<string, { fast?: string; smart?: string }> | null = null;
31
32
  let _modelOverridesAt = 0;
32
33
 
@@ -34,7 +35,7 @@ function loadModelOverrides(): Record<string, { fast?: string; smart?: string }>
34
35
  const now = Date.now();
35
36
  if (_modelOverrides && now - _modelOverridesAt < 30_000) return _modelOverrides;
36
37
  try {
37
- const configPath = join(process.env.HOME ?? "~", ".terminal", "config.json");
38
+ const configPath = join(getTerminalDir(), "config.json");
38
39
  if (existsSync(configPath)) {
39
40
  const config = JSON.parse(readFileSync(configPath, "utf8"));
40
41
  _modelOverrides = config.models ?? {};
package/src/cache.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // In-memory LRU cache + disk persistence for command translations
2
2
 
3
3
  import { existsSync, readFileSync, writeFileSync } from "fs";
4
- import { homedir } from "os";
5
4
  import { join } from "path";
5
+ import { getTerminalDir } from "./paths.js";
6
6
 
7
- const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
7
+ const CACHE_FILE = join(getTerminalDir(), "cache.json");
8
8
  const MAX_ENTRIES = 500;
9
9
 
10
10
  type CacheMap = Record<string, string>;
package/src/economy.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // Token economy tracker — tracks token savings across all interactions
2
2
 
3
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
- import { homedir } from "os";
5
4
  import { join } from "path";
5
+ import { getTerminalDir } from "./paths.js";
6
6
 
7
- const DIR = join(homedir(), ".terminal");
7
+ const DIR = getTerminalDir();
8
8
  const ECONOMY_FILE = join(DIR, "economy.json");
9
9
 
10
10
  export interface EconomyStats {
@@ -129,7 +129,7 @@ const PROVIDER_PRICING: Record<string, { input: number; output: number }> = {
129
129
  "anthropic-opus": { input: 5.00, output: 25.00 },
130
130
  };
131
131
 
132
- /** Load configurable turns-before-compaction from ~/.terminal/config.json */
132
+ /** Load configurable turns-before-compaction from ~/.hasna/terminal/config.json */
133
133
  function loadTurnsMultiplier(): number {
134
134
  try {
135
135
  const configPath = join(DIR, "config.json");
package/src/history.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { join } from "path";
3
+ import { getTerminalDir } from "./paths.js";
4
4
 
5
- const DIR = join(homedir(), ".terminal");
5
+ const DIR = getTerminalDir();
6
6
  const HISTORY_FILE = join(DIR, "history.json");
7
7
  const CONFIG_FILE = join(DIR, "config.json");
8
8
 
package/src/mcp/server.ts CHANGED
@@ -15,6 +15,7 @@ import { registerProcessTools } from "./tools/process.js";
15
15
  import { registerBatchTools } from "./tools/batch.js";
16
16
  import { registerMemoryTools } from "./tools/memory.js";
17
17
  import { registerMetaTools } from "./tools/meta.js";
18
+ import { registerCloudTools } from "@hasna/cloud";
18
19
 
19
20
  // ── server ───────────────────────────────────────────────────────────────────
20
21
 
@@ -49,6 +50,7 @@ export function createServer(): McpServer {
49
50
  registerBatchTools(server, h);
50
51
  registerMemoryTools(server, h);
51
52
  registerMetaTools(server, h);
53
+ registerCloudTools(server, "terminal");
52
54
 
53
55
  return server;
54
56
  }
@@ -136,7 +136,8 @@ export function registerMemoryTools(server: McpServer, h: ToolHelpers): void {
136
136
  async ({ name, value }) => {
137
137
  const { existsSync, readFileSync, writeFileSync, chmodSync } = await import("fs");
138
138
  const { join } = await import("path");
139
- const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
139
+ const { getTerminalDir } = await import("../../paths.js");
140
+ const secretsFile = join(getTerminalDir(), "secrets.json");
140
141
  let secrets: Record<string, string> = {};
141
142
  if (existsSync(secretsFile)) {
142
143
  try { secrets = JSON.parse(readFileSync(secretsFile, "utf8")); } catch {}
@@ -157,7 +158,8 @@ export function registerMemoryTools(server: McpServer, h: ToolHelpers): void {
157
158
  async () => {
158
159
  const { existsSync, readFileSync } = await import("fs");
159
160
  const { join } = await import("path");
160
- const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
161
+ const { getTerminalDir } = await import("../../paths.js");
162
+ const secretsFile = join(getTerminalDir(), "secrets.json");
161
163
  let names: string[] = [];
162
164
  if (existsSync(secretsFile)) {
163
165
  try { names = Object.keys(JSON.parse(readFileSync(secretsFile, "utf8"))); } catch {}
@@ -4,8 +4,9 @@
4
4
  import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { createHash } from "crypto";
7
+ import { getTerminalDir } from "./paths.js";
7
8
 
8
- const OUTPUTS_DIR = join(process.env.HOME ?? "~", ".terminal", "outputs");
9
+ const OUTPUTS_DIR = join(getTerminalDir(), "outputs");
9
10
 
10
11
  /** Ensure outputs directory exists */
11
12
  function ensureDir() {
package/src/paths.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Centralized path resolution for open-terminal global data directory.
2
+ // Migrated from ~/.terminal/ to ~/.hasna/terminal/ with backward compat.
3
+
4
+ import { existsSync, mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+
8
+ /**
9
+ * Get the global terminal data directory.
10
+ * New default: ~/.hasna/terminal/
11
+ * Legacy fallback: ~/.terminal/ (if it exists and new dir doesn't)
12
+ * Env override: HASNA_TERMINAL_DIR or TERMINAL_DIR
13
+ */
14
+ export function getTerminalDir(): string {
15
+ if (process.env.HASNA_TERMINAL_DIR) return process.env.HASNA_TERMINAL_DIR;
16
+ if (process.env.TERMINAL_DIR) return process.env.TERMINAL_DIR;
17
+
18
+ const home = homedir();
19
+ const newDir = join(home, ".hasna", "terminal");
20
+ const legacyDir = join(home, ".terminal");
21
+
22
+ // Use legacy dir if it exists and new one doesn't yet (backward compat)
23
+ if (!existsSync(newDir) && existsSync(legacyDir)) {
24
+ return legacyDir;
25
+ }
26
+
27
+ if (!existsSync(newDir)) {
28
+ mkdirSync(newDir, { recursive: true });
29
+ }
30
+
31
+ return newDir;
32
+ }
@@ -1,12 +1,12 @@
1
- // Recipes storage — global (~/.terminal/recipes.json) + per-project (.terminal/recipes.json)
1
+ // Recipes storage — global (~/.hasna/terminal/recipes.json) + per-project (.terminal/recipes.json)
2
2
 
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
- import { homedir } from "os";
5
4
  import { join } from "path";
6
5
  import type { Recipe, Collection, RecipeStore } from "./model.js";
7
6
  import { genId, extractVariables } from "./model.js";
7
+ import { getTerminalDir } from "../paths.js";
8
8
 
9
- const GLOBAL_DIR = join(homedir(), ".terminal");
9
+ const GLOBAL_DIR = getTerminalDir();
10
10
  const GLOBAL_FILE = join(GLOBAL_DIR, "recipes.json");
11
11
 
12
12
  function projectFile(projectPath: string): string {
@@ -2,10 +2,10 @@
2
2
  // Enables: terminal "show auth code" → terminal "explain that function"
3
3
 
4
4
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
- import { homedir } from "os";
6
5
  import { join } from "path";
6
+ import { getTerminalDir } from "./paths.js";
7
7
 
8
- const DIR = join(homedir(), ".terminal");
8
+ const DIR = getTerminalDir();
9
9
  const CTX_FILE = join(DIR, "session-context.json");
10
10
  const MAX_ENTRIES = 5;
11
11