prjct-cli 0.7.1 → 0.7.3

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.
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import promptBuilder from '../../agentic/prompt-builder.js'
3
+
4
+ describe('Prompt Builder', () => {
5
+ const mockTemplate = {
6
+ frontmatter: {
7
+ 'allowed-tools': ['Read', 'Write', 'Bash'],
8
+ },
9
+ content: '# Command: Test\n\nExecute test command.',
10
+ }
11
+
12
+ const mockContext = {
13
+ projectId: 'test-project-123',
14
+ projectPath: '/test/path',
15
+ globalPath: '/global/path',
16
+ timestamp: '2025-10-04T12:00:00Z',
17
+ date: '2025-10-04',
18
+ params: {},
19
+ paths: {
20
+ now: '/global/path/core/now.md',
21
+ next: '/global/path/core/next.md',
22
+ },
23
+ }
24
+
25
+ const mockState = {
26
+ now: '# Current Task\n\nTest task in progress',
27
+ next: '# Priority Queue\n\nTask 1, Task 2',
28
+ context: null, // Simulate non-existent file
29
+ }
30
+
31
+ describe('build()', () => {
32
+ it('should build a complete prompt', () => {
33
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
34
+
35
+ expect(prompt).toBeDefined()
36
+ expect(typeof prompt).toBe('string')
37
+ expect(prompt.length).toBeGreaterThan(0)
38
+ })
39
+
40
+ it('should include command instructions', () => {
41
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
42
+
43
+ expect(prompt).toContain('# Command Instructions')
44
+ expect(prompt).toContain('Execute test command')
45
+ })
46
+
47
+ it('should include allowed tools', () => {
48
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
49
+
50
+ expect(prompt).toContain('## Allowed Tools')
51
+ expect(prompt).toContain('Read, Write, Bash')
52
+ })
53
+
54
+ it('should include project context', () => {
55
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
56
+
57
+ expect(prompt).toContain('## Project Context')
58
+ expect(prompt).toContain('Project ID: test-project-123')
59
+ expect(prompt).toContain('Timestamp: 2025-10-04T12:00:00Z')
60
+ })
61
+
62
+ it('should include current state', () => {
63
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
64
+
65
+ expect(prompt).toContain('## Current State')
66
+ expect(prompt).toContain('### now')
67
+ expect(prompt).toContain('Test task in progress')
68
+ expect(prompt).toContain('### next')
69
+ expect(prompt).toContain('Task 1, Task 2')
70
+ })
71
+
72
+ it('should exclude null or empty state values', () => {
73
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
74
+
75
+ // Should not include 'context' since it's null
76
+ expect(prompt).not.toContain('### context')
77
+ })
78
+
79
+ it('should include parameters when present', () => {
80
+ const contextWithParams = {
81
+ ...mockContext,
82
+ params: {
83
+ taskName: 'Test Task',
84
+ feature: 'Test Feature',
85
+ },
86
+ }
87
+
88
+ const prompt = promptBuilder.build(mockTemplate, contextWithParams, mockState)
89
+
90
+ expect(prompt).toContain('## Parameters')
91
+ expect(prompt).toContain('taskName: Test Task')
92
+ expect(prompt).toContain('feature: Test Feature')
93
+ })
94
+
95
+ it('should exclude parameters section when empty', () => {
96
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
97
+
98
+ // Should not include Parameters section since params is empty
99
+ const hasParametersSection = prompt.includes('## Parameters')
100
+ expect(hasParametersSection).toBe(false)
101
+ })
102
+
103
+ it('should include final execution instructions', () => {
104
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
105
+
106
+ expect(prompt).toContain('## Execute')
107
+ expect(prompt).toContain('execute the command')
108
+ expect(prompt).toContain('Use ONLY the allowed tools')
109
+ expect(prompt).toContain('do not follow rigid if/else rules')
110
+ })
111
+
112
+ it('should handle template without allowed-tools', () => {
113
+ const templateNoTools = {
114
+ frontmatter: {},
115
+ content: 'Simple command',
116
+ }
117
+
118
+ const prompt = promptBuilder.build(templateNoTools, mockContext, mockState)
119
+
120
+ expect(prompt).toBeDefined()
121
+ expect(prompt).toContain('Simple command')
122
+ // Should not have Allowed Tools section
123
+ expect(prompt).not.toContain('## Allowed Tools')
124
+ })
125
+
126
+ it('should handle empty state', () => {
127
+ const emptyState = {}
128
+
129
+ const prompt = promptBuilder.build(mockTemplate, mockContext, emptyState)
130
+
131
+ expect(prompt).toBeDefined()
132
+ expect(prompt).toContain('## Current State')
133
+ // But no actual state entries
134
+ })
135
+
136
+ it('should format state content in code blocks', () => {
137
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
138
+
139
+ expect(prompt).toContain('```')
140
+ expect(prompt).toMatch(/```\n# Current Task/)
141
+ })
142
+ })
143
+
144
+ describe('buildAnalysis()', () => {
145
+ it('should build analysis prompt', () => {
146
+ const prompt = promptBuilder.buildAnalysis('repository', mockContext)
147
+
148
+ expect(prompt).toBeDefined()
149
+ expect(typeof prompt).toBe('string')
150
+ })
151
+
152
+ it('should include analysis type', () => {
153
+ const prompt = promptBuilder.buildAnalysis('repository', mockContext)
154
+
155
+ expect(prompt).toContain('# Analyze: repository')
156
+ })
157
+
158
+ it('should include project context', () => {
159
+ const prompt = promptBuilder.buildAnalysis('repository', mockContext)
160
+
161
+ expect(prompt).toContain('## Project Context')
162
+ expect(prompt).toContain('Path: /test/path')
163
+ expect(prompt).toContain('ID: test-project-123')
164
+ })
165
+
166
+ it('should include analysis instructions', () => {
167
+ const prompt = promptBuilder.buildAnalysis('repository', mockContext)
168
+
169
+ expect(prompt).toContain('Read the project context')
170
+ expect(prompt).toContain('provide your analysis')
171
+ expect(prompt).toContain('No predetermined patterns')
172
+ })
173
+
174
+ it('should work with different analysis types', () => {
175
+ const types = ['repository', 'feature', 'bug', 'performance']
176
+
177
+ types.forEach((type) => {
178
+ const prompt = promptBuilder.buildAnalysis(type, mockContext)
179
+ expect(prompt).toContain(`# Analyze: ${type}`)
180
+ })
181
+ })
182
+ })
183
+
184
+ describe('Prompt Structure', () => {
185
+ it('should have clear sections in order', () => {
186
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
187
+
188
+ const instructionsIndex = prompt.indexOf('# Command Instructions')
189
+ const toolsIndex = prompt.indexOf('## Allowed Tools')
190
+ const contextIndex = prompt.indexOf('## Project Context')
191
+ const stateIndex = prompt.indexOf('## Current State')
192
+ const executeIndex = prompt.indexOf('## Execute')
193
+
194
+ expect(instructionsIndex).toBeGreaterThan(-1)
195
+ expect(toolsIndex).toBeGreaterThan(instructionsIndex)
196
+ expect(contextIndex).toBeGreaterThan(toolsIndex)
197
+ expect(stateIndex).toBeGreaterThan(contextIndex)
198
+ expect(executeIndex).toBeGreaterThan(stateIndex)
199
+ })
200
+
201
+ it('should use proper markdown formatting', () => {
202
+ const prompt = promptBuilder.build(mockTemplate, mockContext, mockState)
203
+
204
+ // Should have proper headings
205
+ expect(prompt).toMatch(/^# /m)
206
+ expect(prompt).toMatch(/^## /m)
207
+
208
+ // Should have code blocks
209
+ expect(prompt).toContain('```')
210
+ })
211
+ })
212
+ })
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import templateLoader from '../../agentic/template-loader.js'
3
+
4
+ describe('Template Loader', () => {
5
+ beforeEach(() => {
6
+ templateLoader.clearCache()
7
+ })
8
+
9
+ describe('load()', () => {
10
+ it('should load a template successfully', async () => {
11
+ const template = await templateLoader.load('now')
12
+
13
+ expect(template).toBeDefined()
14
+ expect(template).toHaveProperty('content')
15
+ expect(template).toHaveProperty('frontmatter')
16
+ })
17
+
18
+ it('should load multiple templates', async () => {
19
+ const now = await templateLoader.load('now')
20
+ const done = await templateLoader.load('done')
21
+ const next = await templateLoader.load('next')
22
+
23
+ expect(now).toBeDefined()
24
+ expect(done).toBeDefined()
25
+ expect(next).toBeDefined()
26
+ })
27
+
28
+ it('should throw error for non-existent template', async () => {
29
+ await expect(templateLoader.load('nonexistent')).rejects.toThrow('Template not found: nonexistent.md')
30
+ })
31
+
32
+ it('should cache templates', async () => {
33
+ const first = await templateLoader.load('now')
34
+ const second = await templateLoader.load('now')
35
+
36
+ // Should return the same cached object
37
+ expect(first).toBe(second)
38
+ })
39
+ })
40
+
41
+ describe('parseFrontmatter()', () => {
42
+ it('should parse frontmatter correctly', () => {
43
+ const content = `---
44
+ allowed-tools: [Read, Write, Bash]
45
+ category: core
46
+ ---
47
+ # Template Content
48
+
49
+ This is the main content.`
50
+
51
+ const parsed = templateLoader.parseFrontmatter(content)
52
+
53
+ expect(parsed.frontmatter).toHaveProperty('allowed-tools')
54
+ expect(parsed.frontmatter['allowed-tools']).toEqual(['Read', 'Write', 'Bash'])
55
+ expect(parsed.frontmatter.category).toBe('core')
56
+ expect(parsed.content).toContain('# Template Content')
57
+ })
58
+
59
+ it('should handle templates without frontmatter', () => {
60
+ const content = `# Simple Template
61
+
62
+ Just content, no frontmatter.`
63
+
64
+ const parsed = templateLoader.parseFrontmatter(content)
65
+
66
+ expect(parsed.frontmatter).toEqual({})
67
+ expect(parsed.content).toContain('# Simple Template')
68
+ })
69
+
70
+ it('should parse string values', () => {
71
+ const content = `---
72
+ title: Test Command
73
+ description: A test command
74
+ ---
75
+ Content`
76
+
77
+ const parsed = templateLoader.parseFrontmatter(content)
78
+
79
+ expect(parsed.frontmatter.title).toBe('Test Command')
80
+ expect(parsed.frontmatter.description).toBe('A test command')
81
+ })
82
+
83
+ it('should handle quoted strings', () => {
84
+ const content = `---
85
+ title: "Quoted Title"
86
+ description: 'Single Quoted'
87
+ ---
88
+ Content`
89
+
90
+ const parsed = templateLoader.parseFrontmatter(content)
91
+
92
+ expect(parsed.frontmatter.title).toBe('Quoted Title')
93
+ expect(parsed.frontmatter.description).toBe('Single Quoted')
94
+ })
95
+
96
+ it('should parse array values', () => {
97
+ const content = `---
98
+ tools: [Read, Write, Exec]
99
+ tags: [core, important]
100
+ ---
101
+ Content`
102
+
103
+ const parsed = templateLoader.parseFrontmatter(content)
104
+
105
+ expect(Array.isArray(parsed.frontmatter.tools)).toBe(true)
106
+ expect(parsed.frontmatter.tools).toEqual(['Read', 'Write', 'Exec'])
107
+ expect(parsed.frontmatter.tags).toEqual(['core', 'important'])
108
+ })
109
+ })
110
+
111
+ describe('getAllowedTools()', () => {
112
+ it('should return allowed tools from template', async () => {
113
+ const tools = await templateLoader.getAllowedTools('now')
114
+
115
+ expect(Array.isArray(tools)).toBe(true)
116
+ })
117
+
118
+ it('should return empty array if no allowed-tools defined', async () => {
119
+ // Create a mock template without allowed-tools
120
+ const content = `# Simple Template
121
+ No tools defined`
122
+
123
+ const parsed = templateLoader.parseFrontmatter(content)
124
+ expect(parsed.frontmatter['allowed-tools'] || []).toEqual([])
125
+ })
126
+ })
127
+
128
+ describe('clearCache()', () => {
129
+ it('should clear the cache', async () => {
130
+ // Load and cache
131
+ await templateLoader.load('now')
132
+
133
+ // Clear cache
134
+ templateLoader.clearCache()
135
+
136
+ // Load again - should read from file, not cache
137
+ const template = await templateLoader.load('now')
138
+ expect(template).toBeDefined()
139
+ })
140
+ })
141
+
142
+ describe('Real Templates', () => {
143
+ it('should load "now" template', async () => {
144
+ const template = await templateLoader.load('now')
145
+
146
+ expect(template.content).toBeTruthy()
147
+ expect(template.content.length).toBeGreaterThan(0)
148
+ })
149
+
150
+ it('should load "done" template', async () => {
151
+ const template = await templateLoader.load('done')
152
+
153
+ expect(template.content).toBeTruthy()
154
+ expect(template.content.length).toBeGreaterThan(0)
155
+ })
156
+
157
+ it('should load "ship" template', async () => {
158
+ const template = await templateLoader.load('ship')
159
+
160
+ expect(template.content).toBeTruthy()
161
+ expect(template.content.length).toBeGreaterThan(0)
162
+ })
163
+ })
164
+ })
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import toolRegistry from '../../agentic/tool-registry.js'
3
+ import fs from 'fs/promises'
4
+ import path from 'path'
5
+ import os from 'os'
6
+
7
+ describe('Tool Registry', () => {
8
+ const testDir = path.join(os.tmpdir(), 'prjct-test-' + Date.now())
9
+ const testFile = path.join(testDir, 'test.txt')
10
+
11
+ beforeEach(async () => {
12
+ await fs.mkdir(testDir, { recursive: true })
13
+ })
14
+
15
+ afterEach(async () => {
16
+ try {
17
+ await fs.rm(testDir, { recursive: true, force: true })
18
+ } catch (error) {
19
+ // Ignore cleanup errors
20
+ }
21
+ })
22
+
23
+ describe('get()', () => {
24
+ it('should get Read tool', () => {
25
+ const tool = toolRegistry.get('Read')
26
+
27
+ expect(tool).toBeDefined()
28
+ expect(typeof tool).toBe('function')
29
+ })
30
+
31
+ it('should get Write tool', () => {
32
+ const tool = toolRegistry.get('Write')
33
+
34
+ expect(tool).toBeDefined()
35
+ expect(typeof tool).toBe('function')
36
+ })
37
+
38
+ it('should get Bash tool', () => {
39
+ const tool = toolRegistry.get('Bash')
40
+
41
+ expect(tool).toBeDefined()
42
+ expect(typeof tool).toBe('function')
43
+ })
44
+
45
+ it('should get Exec tool (alias for Bash)', () => {
46
+ const tool = toolRegistry.get('Exec')
47
+
48
+ expect(tool).toBeDefined()
49
+ expect(typeof tool).toBe('function')
50
+ })
51
+
52
+ it('should throw error for unknown tool', () => {
53
+ expect(() => toolRegistry.get('UnknownTool')).toThrow('Unknown tool: UnknownTool')
54
+ })
55
+ })
56
+
57
+ describe('isAllowed()', () => {
58
+ it('should return true for allowed tool', () => {
59
+ const allowed = toolRegistry.isAllowed('Read', ['Read', 'Write', 'Bash'])
60
+
61
+ expect(allowed).toBe(true)
62
+ })
63
+
64
+ it('should return false for disallowed tool', () => {
65
+ const allowed = toolRegistry.isAllowed('Exec', ['Read', 'Write'])
66
+
67
+ expect(allowed).toBe(false)
68
+ })
69
+
70
+ it('should handle empty allowed list', () => {
71
+ const allowed = toolRegistry.isAllowed('Read', [])
72
+
73
+ expect(allowed).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe('read()', () => {
78
+ it('should read file content', async () => {
79
+ const content = 'Test content for reading'
80
+ await fs.writeFile(testFile, content)
81
+
82
+ const result = await toolRegistry.read(testFile)
83
+
84
+ expect(result).toBe(content)
85
+ })
86
+
87
+ it('should return null for non-existent file', async () => {
88
+ const result = await toolRegistry.read('/nonexistent/file.txt')
89
+
90
+ expect(result).toBeNull()
91
+ })
92
+
93
+ it('should read UTF-8 encoded content', async () => {
94
+ const content = 'UTF-8: ñ é ü 中文 日本語'
95
+ await fs.writeFile(testFile, content)
96
+
97
+ const result = await toolRegistry.read(testFile)
98
+
99
+ expect(result).toBe(content)
100
+ })
101
+ })
102
+
103
+ describe('write()', () => {
104
+ it('should write file content', async () => {
105
+ const content = 'Test content for writing'
106
+
107
+ await toolRegistry.write(testFile, content)
108
+
109
+ const result = await fs.readFile(testFile, 'utf-8')
110
+ expect(result).toBe(content)
111
+ })
112
+
113
+ it('should create directory if not exists', async () => {
114
+ const nestedFile = path.join(testDir, 'nested', 'dir', 'file.txt')
115
+ const content = 'Nested content'
116
+
117
+ await toolRegistry.write(nestedFile, content)
118
+
119
+ const result = await fs.readFile(nestedFile, 'utf-8')
120
+ expect(result).toBe(content)
121
+ })
122
+
123
+ it('should overwrite existing file', async () => {
124
+ await toolRegistry.write(testFile, 'First content')
125
+ await toolRegistry.write(testFile, 'Second content')
126
+
127
+ const result = await fs.readFile(testFile, 'utf-8')
128
+ expect(result).toBe('Second content')
129
+ })
130
+ })
131
+
132
+ describe('bash()', () => {
133
+ it('should execute simple command', async () => {
134
+ const result = await toolRegistry.bash('echo "hello"')
135
+
136
+ expect(result.stdout).toContain('hello')
137
+ expect(result.stderr).toBe('')
138
+ })
139
+
140
+ it('should return stdout and stderr', async () => {
141
+ const result = await toolRegistry.bash('echo "output"')
142
+
143
+ expect(result).toHaveProperty('stdout')
144
+ expect(result).toHaveProperty('stderr')
145
+ })
146
+
147
+ it('should handle command errors', async () => {
148
+ const result = await toolRegistry.bash('invalid-command-xyz-123')
149
+
150
+ expect(result.error).toBe(true)
151
+ expect(result.stderr).toBeTruthy()
152
+ })
153
+
154
+ it('should execute pwd command', async () => {
155
+ const result = await toolRegistry.bash('pwd')
156
+
157
+ expect(result.stdout).toBeTruthy()
158
+ expect(result.stderr).toBe('')
159
+ })
160
+ })
161
+
162
+ describe('exists()', () => {
163
+ it('should return true for existing file', async () => {
164
+ await fs.writeFile(testFile, 'content')
165
+
166
+ const exists = await toolRegistry.exists(testFile)
167
+
168
+ expect(exists).toBe(true)
169
+ })
170
+
171
+ it('should return false for non-existent file', async () => {
172
+ const exists = await toolRegistry.exists('/nonexistent/file.txt')
173
+
174
+ expect(exists).toBe(false)
175
+ })
176
+
177
+ it('should work with directories', async () => {
178
+ const exists = await toolRegistry.exists(testDir)
179
+
180
+ expect(exists).toBe(true)
181
+ })
182
+ })
183
+
184
+ describe('list()', () => {
185
+ it('should list directory contents', async () => {
186
+ await fs.writeFile(path.join(testDir, 'file1.txt'), 'content1')
187
+ await fs.writeFile(path.join(testDir, 'file2.txt'), 'content2')
188
+
189
+ const files = await toolRegistry.list(testDir)
190
+
191
+ expect(Array.isArray(files)).toBe(true)
192
+ expect(files.length).toBeGreaterThanOrEqual(2)
193
+ expect(files).toContain('file1.txt')
194
+ expect(files).toContain('file2.txt')
195
+ })
196
+
197
+ it('should return empty array for non-existent directory', async () => {
198
+ const files = await toolRegistry.list('/nonexistent/directory')
199
+
200
+ expect(files).toEqual([])
201
+ })
202
+
203
+ it('should list empty directory', async () => {
204
+ const emptyDir = path.join(testDir, 'empty')
205
+ await fs.mkdir(emptyDir)
206
+
207
+ const files = await toolRegistry.list(emptyDir)
208
+
209
+ expect(Array.isArray(files)).toBe(true)
210
+ expect(files.length).toBe(0)
211
+ })
212
+ })
213
+
214
+ describe('Integration', () => {
215
+ it('should write and then read file', async () => {
216
+ const content = 'Integration test content'
217
+
218
+ await toolRegistry.write(testFile, content)
219
+ const result = await toolRegistry.read(testFile)
220
+
221
+ expect(result).toBe(content)
222
+ })
223
+
224
+ it('should check existence, write, and list', async () => {
225
+ const newFile = path.join(testDir, 'integration.txt')
226
+
227
+ // Check doesn't exist
228
+ const existsBefore = await toolRegistry.exists(newFile)
229
+ expect(existsBefore).toBe(false)
230
+
231
+ // Write
232
+ await toolRegistry.write(newFile, 'content')
233
+
234
+ // Check exists
235
+ const existsAfter = await toolRegistry.exists(newFile)
236
+ expect(existsAfter).toBe(true)
237
+
238
+ // List directory
239
+ const files = await toolRegistry.list(testDir)
240
+ expect(files).toContain('integration.txt')
241
+ })
242
+ })
243
+ })