pelulu-cli 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.
Files changed (56) hide show
  1. package/README.md +273 -0
  2. package/config.example.json +24 -0
  3. package/package.json +44 -0
  4. package/src/core/auto-format.js +47 -0
  5. package/src/core/completion.js +65 -0
  6. package/src/core/config.js +68 -0
  7. package/src/core/confirm.js +51 -0
  8. package/src/core/context.js +69 -0
  9. package/src/core/conversation.js +57 -0
  10. package/src/core/diff.js +50 -0
  11. package/src/core/doctor.js +60 -0
  12. package/src/core/error-handler.js +39 -0
  13. package/src/core/event-bus.js +43 -0
  14. package/src/core/file-tracker.js +44 -0
  15. package/src/core/formatter.js +83 -0
  16. package/src/core/intent.js +81 -0
  17. package/src/core/keybindings.js +29 -0
  18. package/src/core/logger.js +67 -0
  19. package/src/core/model-info.js +26 -0
  20. package/src/core/retry.js +26 -0
  21. package/src/core/sandbox.js +54 -0
  22. package/src/core/session.js +46 -0
  23. package/src/core/spinner.js +60 -0
  24. package/src/core/stats.js +73 -0
  25. package/src/core/system-prompt.js +42 -0
  26. package/src/core/thinking.js +39 -0
  27. package/src/core/tool-help.js +117 -0
  28. package/src/core/tool-registry.js +82 -0
  29. package/src/core/wizard.js +55 -0
  30. package/src/core/workspace.js +96 -0
  31. package/src/index.js +150 -0
  32. package/src/mcp/activation.js +54 -0
  33. package/src/mcp/mcp-handler.js +92 -0
  34. package/src/mcp/message-sender.js +147 -0
  35. package/src/mcp/mqtt-client.js +160 -0
  36. package/src/mcp/wss-endpoint.js +133 -0
  37. package/src/plugins/manager.js +72 -0
  38. package/src/repl-commands.js +91 -0
  39. package/src/repl.js +115 -0
  40. package/src/tools/ai.js +128 -0
  41. package/src/tools/config.js +87 -0
  42. package/src/tools/diff.js +118 -0
  43. package/src/tools/env.js +58 -0
  44. package/src/tools/file.js +177 -0
  45. package/src/tools/git.js +166 -0
  46. package/src/tools/history.js +64 -0
  47. package/src/tools/network.js +87 -0
  48. package/src/tools/process.js +69 -0
  49. package/src/tools/project.js +152 -0
  50. package/src/tools/search.js +100 -0
  51. package/src/tools/shell.js +91 -0
  52. package/src/tools/snippet.js +95 -0
  53. package/src/tools/template.js +113 -0
  54. package/src/tools/watch.js +87 -0
  55. package/src/tui/renderer.js +119 -0
  56. package/src/tui/status-bar.js +34 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * SessionState — tracks conversation context, tool history, and working state
3
+ */
4
+ export class SessionState {
5
+ constructor() {
6
+ this.messages = [];
7
+ this.toolCalls = [];
8
+ this.startTime = Date.now();
9
+ this.turnCount = 0;
10
+ this.cwd = process.cwd();
11
+ }
12
+
13
+ addUserMessage(text) {
14
+ this.messages.push({ role: 'user', text, ts: Date.now() });
15
+ this.turnCount++;
16
+ // Keep last 50 messages
17
+ if (this.messages.length > 50) this.messages = this.messages.slice(-50);
18
+ }
19
+
20
+ addAiMessage(text) {
21
+ this.messages.push({ role: 'ai', text, ts: Date.now() });
22
+ }
23
+
24
+ addToolCall(name, args, result) {
25
+ this.toolCalls.push({ name, args, result: result?.isError ? 'error' : 'ok', ts: Date.now() });
26
+ // Keep last 100 tool calls
27
+ if (this.toolCalls.length > 100) this.toolCalls = this.toolCalls.slice(-100);
28
+ }
29
+
30
+ getRecentContext(n = 10) {
31
+ return this.messages.slice(-n);
32
+ }
33
+
34
+ getStats() {
35
+ return {
36
+ uptime: Math.round((Date.now() - this.startTime) / 1000),
37
+ turns: this.turnCount,
38
+ messages: this.messages.length,
39
+ toolCalls: this.toolCalls.length,
40
+ errors: this.toolCalls.filter(t => t.result === 'error').length,
41
+ };
42
+ }
43
+
44
+ getCwd() { return this.cwd; }
45
+ setCwd(path) { this.cwd = path; }
46
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Spinner — progress indicator for long operations
3
+ */
4
+ import { COLORS } from './logger.js';
5
+
6
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+
8
+ export class Spinner {
9
+ constructor(text = 'Working...') {
10
+ this.text = text;
11
+ this.i = 0;
12
+ this.timer = null;
13
+ this.stream = process.stderr;
14
+ }
15
+
16
+ start(text) {
17
+ if (text) this.text = text;
18
+ this.i = 0;
19
+ this.timer = setInterval(() => {
20
+ this.stream.write(`\r${COLORS.cyan}${FRAMES[this.i % FRAMES.length]}${COLORS.reset} ${this.text}`);
21
+ this.i++;
22
+ }, 80);
23
+ return this;
24
+ }
25
+
26
+ update(text) {
27
+ this.text = text;
28
+ return this;
29
+ }
30
+
31
+ stop(finalText) {
32
+ if (this.timer) {
33
+ clearInterval(this.timer);
34
+ this.timer = null;
35
+ }
36
+ this.stream.write(`\r${' '.repeat(this.text.length + 4)}\r`);
37
+ if (finalText) console.log(finalText);
38
+ return this;
39
+ }
40
+
41
+ success(text) {
42
+ this.stop(`${COLORS.green}✅ ${text}${COLORS.reset}`);
43
+ }
44
+
45
+ fail(text) {
46
+ this.stop(`${COLORS.red}❌ ${text}${COLORS.reset}`);
47
+ }
48
+ }
49
+
50
+ export async function withSpinner(text, fn) {
51
+ const spinner = new Spinner(text).start();
52
+ try {
53
+ const result = await fn(spinner);
54
+ spinner.success(text);
55
+ return result;
56
+ } catch (e) {
57
+ spinner.fail(`${text}: ${e.message}`);
58
+ throw e;
59
+ }
60
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Stats — tool usage analytics and session metrics
3
+ */
4
+ export class Stats {
5
+ constructor() {
6
+ this.toolCalls = [];
7
+ this.startTime = Date.now();
8
+ this.errors = [];
9
+ }
10
+
11
+ record(toolName, action, success, duration, error) {
12
+ this.toolCalls.push({
13
+ tool: toolName,
14
+ action,
15
+ success,
16
+ duration,
17
+ ts: Date.now(),
18
+ });
19
+ if (!success && error) this.errors.push({ tool: toolName, action, error, ts: Date.now() });
20
+ // Keep last 500 calls
21
+ if (this.toolCalls.length > 500) this.toolCalls = this.toolCalls.slice(-500);
22
+ }
23
+
24
+ getSummary() {
25
+ const total = this.toolCalls.length;
26
+ const success = this.toolCalls.filter(t => t.success).length;
27
+ const failed = total - success;
28
+ const avgDuration = total ? Math.round(this.toolCalls.reduce((s, t) => s + t.duration, 0) / total) : 0;
29
+ const uptime = Math.round((Date.now() - this.startTime) / 1000);
30
+
31
+ // Tool frequency
32
+ const freq = {};
33
+ for (const t of this.toolCalls) {
34
+ freq[t.tool] = (freq[t.tool] || 0) + 1;
35
+ }
36
+ const topTools = Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 5);
37
+
38
+ return { total, success, failed, avgDuration, uptime, topTools };
39
+ }
40
+
41
+ formatReport() {
42
+ const s = this.getSummary();
43
+ const lines = [
44
+ `📊 Session Stats`,
45
+ ` Uptime: ${s.uptime}s`,
46
+ ` Tool calls: ${s.total} (${s.success} ✅ / ${s.failed} ❌)`,
47
+ ` Avg duration: ${s.avgDuration}ms`,
48
+ ];
49
+ if (s.topTools.length) {
50
+ lines.push(' Top tools:');
51
+ for (const [tool, count] of s.topTools) {
52
+ lines.push(` ${tool}: ${count} calls`);
53
+ }
54
+ }
55
+ if (this.errors.length) {
56
+ lines.push(` Recent errors:`);
57
+ for (const e of this.errors.slice(-3)) {
58
+ lines.push(` ${e.tool}.${e.action}: ${e.error.slice(0, 60)}`);
59
+ }
60
+ }
61
+ return lines.join('\n');
62
+ }
63
+
64
+ getToolStats(toolName) {
65
+ const calls = this.toolCalls.filter(t => t.tool === toolName);
66
+ return {
67
+ tool: toolName,
68
+ calls: calls.length,
69
+ success: calls.filter(t => t.success).length,
70
+ avgDuration: calls.length ? Math.round(calls.reduce((s, t) => s + t.duration, 0) / calls.length) : 0,
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * SystemPrompt — builds context prompt for XiaoZhi LLM
3
+ * Tells the AI what tools are available and how to use them
4
+ */
5
+
6
+ export function buildSystemPrompt(registry, config) {
7
+ const tools = registry.list();
8
+ const lines = [
9
+ `You are ${config.agent?.name || 'a coding agent'} running in Termux/Node.js.`,
10
+ `You have access to ${tools.length} MCP tools. Use them to help the user.`,
11
+ '',
12
+ '## Available Tools',
13
+ '',
14
+ ];
15
+
16
+ for (const tool of tools) {
17
+ lines.push(`### ${tool.name}`);
18
+ lines.push(`${tool.description}`);
19
+ lines.push(`Actions: ${tool.actions.join(', ')}`);
20
+ lines.push('');
21
+ }
22
+
23
+ lines.push('## Tool Call Format');
24
+ lines.push('When you need to use a tool, call it with the action and required parameters.');
25
+ lines.push('Example: file(action="read", path="./src/index.js")');
26
+ lines.push('Example: shell(action="exec", command="npm test")');
27
+ lines.push('Example: git(action="status")');
28
+ lines.push('');
29
+
30
+ lines.push('## Guidelines');
31
+ lines.push('- Always read files before editing them');
32
+ lines.push('- Use git status before committing');
33
+ lines.push('- Run tests after making changes');
34
+ lines.push('- Be concise in explanations');
35
+ lines.push('- Show code changes clearly');
36
+
37
+ return lines.join('\n');
38
+ }
39
+
40
+ export function buildToolHint(toolName, action) {
41
+ return `[Calling ${toolName}.${action}]`;
42
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Thinking — AI processing indicator
3
+ * Shows thinking state like Claude Code's "thinking..." display
4
+ */
5
+ import { COLORS } from './logger.js';
6
+ import { bus } from './event-bus.js';
7
+
8
+ const THINK_STATES = {
9
+ idle: { icon: '💤', text: 'Idle' },
10
+ thinking: { icon: '🤔', text: 'Thinking...' },
11
+ tool_call: { icon: '🔧', text: 'Using tool...' },
12
+ reading: { icon: '📖', text: 'Reading file...' },
13
+ writing: { icon: '✏️', text: 'Writing...' },
14
+ searching: { icon: '🔍', text: 'Searching...' },
15
+ building: { icon: '🔨', text: 'Building...' },
16
+ testing: { icon: '🧪', text: 'Testing...' },
17
+ };
18
+
19
+ export class Thinking {
20
+ constructor() {
21
+ this.state = 'idle';
22
+ this.timer = null;
23
+ }
24
+
25
+ set(state) {
26
+ this.state = state;
27
+ const info = THINK_STATES[state] || THINK_STATES.idle;
28
+ bus.emit('thinking', { state, ...info });
29
+ }
30
+
31
+ get() {
32
+ return THINK_STATES[this.state] || THINK_STATES.idle;
33
+ }
34
+
35
+ format() {
36
+ const info = this.get();
37
+ return `${COLORS.dim}${info.icon} ${info.text}${COLORS.reset}`;
38
+ }
39
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ToolHelp — detailed help for each tool with examples
3
+ */
4
+ import { COLORS } from './logger.js';
5
+
6
+ const TOOL_EXAMPLES = {
7
+ ai: [
8
+ '/call ai analyze {"path":"./src/index.js"}',
9
+ '/call ai summarize {"path":"./src/index.js"}',
10
+ '/call ai diff {"file1":"./old.js","file2":"./new.js"}',
11
+ ],
12
+ env: [
13
+ '/call env get {"name":"HOME"}',
14
+ '/call env list {"filter":"NODE"}',
15
+ ],
16
+ snippet: [
17
+ '/call snippet save {"name":"hello","code":"console.log(1)","language":"js"}',
18
+ '/call snippet load {"name":"hello"}',
19
+ '/call snippet list',
20
+ ],
21
+ template: [
22
+ '/call template list',
23
+ '/call template create {"template":"node-basic","name":"my-app"}',
24
+ '/call template info {"template":"python-basic"}',
25
+ ],
26
+ config: [
27
+ '/call config list',
28
+ '/call config get {"key":"tools.shell_timeout"}',
29
+ '/call config set {"key":"tools.shell_timeout","value":"60000"}',
30
+ ],
31
+ diff: [
32
+ '/call diff compare {"file1":"./old.js","file2":"./new.js"}',
33
+ '/call diff stats {"file1":"./a.txt","file2":"./b.txt"}',
34
+ '/call diff patch {"file1":"./a.py","file2":"./b.py"}',
35
+ ],
36
+ watch: [
37
+ '/call watch start {"path":"./src"}',
38
+ '/call watch stop {"path":"./src"}',
39
+ '/call watch status',
40
+ ],
41
+ file: [
42
+ '/call file read {"path":"./src/index.js"}',
43
+ '/call file write {"path":"./test.js","content":"console.log(1)"}',
44
+ '/call file edit {"path":"./src/index.js","old_text":"foo","new_text":"bar"}',
45
+ '/call file list {"path":".","recursive":true}',
46
+ '/call file exists {"path":"./package.json"}',
47
+ ],
48
+ shell: [
49
+ '/call shell exec {"command":"npm test"}',
50
+ '/call shell exec {"command":"ls -la","timeout":5000}',
51
+ '/call shell ps {"filter":"node"}',
52
+ '/call shell kill {"pid":1234}',
53
+ ],
54
+ git: [
55
+ '/call git status',
56
+ '/call git diff {"staged":true}',
57
+ '/call git log {"limit":5}',
58
+ '/call git commit {"message":"fix: bug"}',
59
+ '/call git push',
60
+ ],
61
+ search: [
62
+ '/call search grep {"pattern":"TODO","path":"./src"}',
63
+ '/call search find {"name":"*.js","path":"./src"}',
64
+ '/call search web {"url":"https://example.com"}',
65
+ ],
66
+ project: [
67
+ '/call project info',
68
+ '/call project build',
69
+ '/call project test',
70
+ '/call project lint',
71
+ '/call project deps {"install":true}',
72
+ ],
73
+ process: [
74
+ '/call process list',
75
+ '/call process top {"limit":10}',
76
+ '/call process info {"pid":1234}',
77
+ ],
78
+ network: [
79
+ '/call network fetch {"url":"https://api.github.com"}',
80
+ '/call network download {"url":"https://example.com/file.zip","path":"./file.zip"}',
81
+ '/call network ping {"host":"google.com"}',
82
+ ],
83
+ env: [
84
+ '/call env get {"name":"HOME"}',
85
+ '/call env list {"filter":"NODE"}',
86
+ ],
87
+ ai: [
88
+ '/call ai analyze {"path":"./src/index.js"}',
89
+ '/call ai summarize {"path":"./src/index.js"}',
90
+ '/call ai diff {"file1":"./old.js","file2":"./new.js"}',
91
+ ],
92
+ };
93
+
94
+ export function showToolHelp(toolName) {
95
+ const examples = TOOL_EXAMPLES[toolName];
96
+ if (!examples) {
97
+ console.log(`${COLORS.yellow}No help for: ${toolName}${COLORS.reset}`);
98
+ console.log(`Available: ${Object.keys(TOOL_EXAMPLES).join(', ')}`);
99
+ return;
100
+ }
101
+
102
+ console.log(`\n${COLORS.bold}📖 ${toolName} — Examples:${COLORS.reset}\n`);
103
+ for (const ex of examples) {
104
+ console.log(` ${COLORS.cyan}${ex}${COLORS.reset}`);
105
+ }
106
+ console.log();
107
+ }
108
+
109
+ export function showAllToolHelp() {
110
+ for (const [tool, examples] of Object.entries(TOOL_EXAMPLES)) {
111
+ console.log(`\n${COLORS.bold}📖 ${tool}:${COLORS.reset}`);
112
+ for (const ex of examples.slice(0, 2)) {
113
+ console.log(` ${COLORS.cyan}${ex}${COLORS.reset}`);
114
+ }
115
+ }
116
+ console.log();
117
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ToolRegistry — central registry for consolidated MCP tools
3
+ *
4
+ * Pattern: Each tool has name, description, inputSchema with "action" enum.
5
+ * Handler receives { action, ...params } and routes to the action handler.
6
+ *
7
+ * This keeps us well under XiaoZhi's 32 MCP tool limit.
8
+ */
9
+ import { readdir } from 'fs/promises';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { log, debug } from './logger.js';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const TOOLS_DIR = join(__dirname, '..', 'tools');
16
+
17
+ export class ToolRegistry {
18
+ #tools = new Map();
19
+
20
+ async loadBuiltins() {
21
+ try {
22
+ const files = await readdir(TOOLS_DIR);
23
+ for (const file of files.filter(f => f.endsWith('.js'))) {
24
+ try {
25
+ const mod = await import(join(TOOLS_DIR, file));
26
+ const tool = mod.default || mod;
27
+ if (tool?.name && tool?.handler) {
28
+ this.register(tool);
29
+ log('tool', ` ${tool.name} — ${tool.description || ''} (${tool.actions?.length || 0} actions)`);
30
+ }
31
+ } catch (e) {
32
+ log('warn', `Tool "${file}" failed: ${e.message}`);
33
+ }
34
+ }
35
+ } catch (e) {
36
+ log('warn', `Tools dir not found: ${e.message}`);
37
+ }
38
+ }
39
+
40
+ register(tool) {
41
+ if (!tool.name || !tool.handler) throw new Error('Tool needs name + handler');
42
+ this.#tools.set(tool.name, tool);
43
+ }
44
+
45
+ get(name) { return this.#tools.get(name); }
46
+
47
+ all() { return [...this.#tools.values()]; }
48
+
49
+ list() {
50
+ return this.all().map(t => ({
51
+ name: t.name,
52
+ description: t.description,
53
+ actions: t.actions?.map(a => a.name) || [],
54
+ }));
55
+ }
56
+
57
+ toMcpTools() {
58
+ return this.all().map(t => ({
59
+ name: t.name,
60
+ description: t.description,
61
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
62
+ }));
63
+ }
64
+
65
+ async call(name, args = {}) {
66
+ const tool = this.#tools.get(name);
67
+ if (!tool) return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
68
+ try {
69
+ const result = await tool.handler(args);
70
+ return { isError: false, content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
71
+ } catch (e) {
72
+ return { isError: true, content: [{ type: 'text', text: e.message }] };
73
+ }
74
+ }
75
+
76
+ async shutdown() {
77
+ for (const tool of this.#tools.values()) {
78
+ if (tool.shutdown) await tool.shutdown();
79
+ }
80
+ this.#tools.clear();
81
+ }
82
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Wizard — first-run configuration wizard
3
+ */
4
+ import { createInterface } from 'readline';
5
+ import { existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { getConfig, saveConfig } from './config.js';
8
+ import { COLORS } from './logger.js';
9
+
10
+ export async function runWizard(root) {
11
+ const config = getConfig();
12
+ if (config._wizard_done) return config;
13
+
14
+ console.log(`\n${COLORS.cyan}╔══════════════════════════════════════╗`);
15
+ console.log(`║ 🐾 Pelulu CLI Setup ║`);
16
+ console.log(`╚══════════════════════════════════════╝${COLORS.reset}\n`);
17
+
18
+ if (!process.stdin.isTTY) {
19
+ console.log(`${COLORS.yellow}Non-interactive mode. Using defaults.${COLORS.reset}`);
20
+ config._wizard_done = true;
21
+ await saveConfig(root, config);
22
+ return config;
23
+ }
24
+
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
26
+ const ask = (q, def) => new Promise((resolve) => {
27
+ rl.question(`${q}${def ? ` ${COLORS.dim}[${def}]${COLORS.reset}` : ''}: `, (a) => {
28
+ resolve(a.trim() || def);
29
+ });
30
+ });
31
+
32
+ try {
33
+ // Workspace
34
+ const workspace = await ask('📁 Workspace path?', config.agent?.workspace || '~/coding-agent');
35
+ config.agent = { ...config.agent, workspace };
36
+
37
+ // MCP endpoint (optional)
38
+ console.log(`\n${COLORS.dim}MCP Endpoint (optional, for official XiaoZhi MCP):${COLORS.reset}`);
39
+ console.log(`${COLORS.dim}Get from: xiaozhi.me → Configure → Extensions → MCP Endpoint${COLORS.reset}`);
40
+ const mcpUrl = await ask('🔗 MCP Endpoint URL?', '');
41
+ if (mcpUrl) config.mcp = { ...config.mcp, endpoint_url: mcpUrl };
42
+
43
+ // Shell timeout
44
+ const timeout = await ask('⏱️ Shell timeout (ms)?', String(config.tools?.shell_timeout || 30000));
45
+ config.tools = { ...config.tools, shell_timeout: parseInt(timeout) || 30000 };
46
+
47
+ config._wizard_done = true;
48
+ await saveConfig(root, config);
49
+
50
+ console.log(`\n${COLORS.green}✅ Configuration saved!${COLORS.reset}\n`);
51
+ return config;
52
+ } finally {
53
+ rl.close();
54
+ }
55
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Workspace — auto-detect project type, structure, and capabilities
3
+ */
4
+ import { readFile, readdir } from 'fs/promises';
5
+ import { existsSync } from 'fs';
6
+ import { join, basename } from 'path';
7
+ import { getConfig } from './config.js';
8
+
9
+ const PROJECT_TYPES = {
10
+ 'package.json': { type: 'node', lang: 'javascript/typescript' },
11
+ 'requirements.txt': { type: 'python', lang: 'python' },
12
+ 'pyproject.toml': { type: 'python', lang: 'python' },
13
+ 'Cargo.toml': { type: 'rust', lang: 'rust' },
14
+ 'go.mod': { type: 'go', lang: 'go' },
15
+ 'CMakeLists.txt': { type: 'cmake', lang: 'c/c++' },
16
+ 'Makefile': { type: 'make', lang: 'varies' },
17
+ 'pom.xml': { type: 'maven', lang: 'java' },
18
+ 'build.gradle': { type: 'gradle', lang: 'java/kotlin' },
19
+ 'Gemfile': { type: 'ruby', lang: 'ruby' },
20
+ 'composer.json': { type: 'composer', lang: 'php' },
21
+ };
22
+
23
+ export async function detectWorkspace(dir) {
24
+ const cwd = dir || getConfig().agent?.workspace || process.cwd();
25
+ const info = {
26
+ path: cwd,
27
+ name: basename(cwd),
28
+ type: 'unknown',
29
+ language: 'unknown',
30
+ files: [],
31
+ structure: {},
32
+ };
33
+
34
+ // Detect project type
35
+ for (const [file, meta] of Object.entries(PROJECT_TYPES)) {
36
+ if (existsSync(join(cwd, file))) {
37
+ info.type = meta.type;
38
+ info.language = meta.lang;
39
+ info.marker = file;
40
+ break;
41
+ }
42
+ }
43
+
44
+ // Read key config files
45
+ if (info.type === 'node') {
46
+ try {
47
+ const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf-8'));
48
+ info.name = pkg.name || info.name;
49
+ info.version = pkg.version;
50
+ info.scripts = Object.keys(pkg.scripts || {});
51
+ info.dependencies = Object.keys(pkg.dependencies || {});
52
+ info.devDependencies = Object.keys(pkg.devDependencies || {});
53
+ info.moduleType = pkg.type || 'commonjs';
54
+ } catch {}
55
+ }
56
+
57
+ if (info.type === 'python') {
58
+ try {
59
+ if (existsSync(join(cwd, 'pyproject.toml'))) {
60
+ const content = await readFile(join(cwd, 'pyproject.toml'), 'utf-8');
61
+ const nameMatch = content.match(/name\s*=\s*"([^"]+)"/);
62
+ if (nameMatch) info.name = nameMatch[1];
63
+ }
64
+ } catch {}
65
+ }
66
+
67
+ // Detect common directories
68
+ const commonDirs = ['src', 'lib', 'test', 'tests', 'spec', 'docs', 'public', 'dist', 'build', 'bin'];
69
+ try {
70
+ const entries = await readdir(cwd, { withFileTypes: true });
71
+ info.dirs = entries.filter(e => e.isDirectory() && commonDirs.includes(e.name)).map(e => e.name);
72
+ info.files = entries.filter(e => e.isFile()).map(e => e.name).slice(0, 30);
73
+ } catch {}
74
+
75
+ // Detect git
76
+ info.hasGit = existsSync(join(cwd, '.git'));
77
+ info.hasDocker = existsSync(join(cwd, 'Dockerfile')) || existsSync(join(cwd, 'docker-compose.yml'));
78
+ info.hasCI = existsSync(join(cwd, '.github')) || existsSync(join(cwd, '.gitlab-ci.yml'));
79
+
80
+ return info;
81
+ }
82
+
83
+ export function formatWorkspace(info) {
84
+ const lines = [
85
+ `📁 ${info.name} (${info.type})`,
86
+ ` Language: ${info.language}`,
87
+ ` Path: ${info.path}`,
88
+ ];
89
+ if (info.version) lines.push(` Version: ${info.version}`);
90
+ if (info.scripts?.length) lines.push(` Scripts: ${info.scripts.join(', ')}`);
91
+ if (info.dirs?.length) lines.push(` Dirs: ${info.dirs.join(', ')}`);
92
+ if (info.hasGit) lines.push(' Git: ✅');
93
+ if (info.hasDocker) lines.push(' Docker: ✅');
94
+ if (info.hasCI) lines.push(' CI: ✅');
95
+ return lines.join('\n');
96
+ }