acp-vscode 0.3.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 (39) hide show
  1. package/.eslintrc.json +12 -0
  2. package/.github/workflows/ci.yml +21 -0
  3. package/.github/workflows/release.yml +50 -0
  4. package/CONTRIBUTING.md +57 -0
  5. package/PRD.md +25 -0
  6. package/README.md +236 -0
  7. package/__tests__/cache.test.js +7 -0
  8. package/__tests__/commands-actions.test.js +58 -0
  9. package/__tests__/commands.test.js +28 -0
  10. package/__tests__/e2e.test.js +40 -0
  11. package/__tests__/fetcher-tree.test.js +55 -0
  12. package/__tests__/fetcher.test.js +122 -0
  13. package/__tests__/install-ambiguous.test.js +14 -0
  14. package/__tests__/install-command-multi.test.js +37 -0
  15. package/__tests__/install-command.test.js +40 -0
  16. package/__tests__/install-extra.test.js +50 -0
  17. package/__tests__/install-multiple-names.test.js +35 -0
  18. package/__tests__/installer-multi.test.js +22 -0
  19. package/__tests__/installer-raw-and-resolve.test.js +62 -0
  20. package/__tests__/installer-user.test.js +19 -0
  21. package/__tests__/installer.test.js +14 -0
  22. package/__tests__/list-format.test.js +31 -0
  23. package/__tests__/list-items-conflict.test.js +15 -0
  24. package/__tests__/list-json.test.js +35 -0
  25. package/__tests__/search-index.test.js +14 -0
  26. package/__tests__/search-json.test.js +34 -0
  27. package/__tests__/uninstall-prefixed.test.js +52 -0
  28. package/__tests__/uninstall.test.js +21 -0
  29. package/bin/acp-vscode.js +42 -0
  30. package/jest.config.cjs +3 -0
  31. package/package.json +44 -0
  32. package/src/cache.js +19 -0
  33. package/src/commands/completion.js +14 -0
  34. package/src/commands/install.js +278 -0
  35. package/src/commands/list.js +145 -0
  36. package/src/commands/search.js +84 -0
  37. package/src/commands/uninstall.js +55 -0
  38. package/src/fetcher.js +190 -0
  39. package/src/installer.js +158 -0
@@ -0,0 +1,122 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const axios = require('axios');
5
+ jest.mock('axios');
6
+
7
+ let fetcher;
8
+ let fetchIndex;
9
+ let diskPaths;
10
+ const cache = require('../src/cache');
11
+
12
+ describe('fetcher', () => {
13
+ const tmp = path.join(os.tmpdir(), `acp-fetcher-${Date.now()}`);
14
+ const origCwd = process.cwd();
15
+
16
+ beforeAll(async () => {
17
+ await fs.ensureDir(tmp);
18
+ process.chdir(tmp);
19
+ // require after chdir so diskPaths uses tmp cwd
20
+ fetcher = require('../src/fetcher');
21
+ fetchIndex = fetcher.fetchIndex;
22
+ diskPaths = fetcher.diskPaths;
23
+ });
24
+
25
+ afterAll(async () => {
26
+ process.chdir(origCwd);
27
+ await fs.remove(tmp);
28
+ });
29
+
30
+ beforeEach(async () => {
31
+ cache.del('index');
32
+ const { DISK_CACHE_DIR } = diskPaths();
33
+ await fs.remove(DISK_CACHE_DIR).catch(() => {});
34
+ axios.get.mockReset();
35
+ });
36
+
37
+ test('fetches remote when no cache', async () => {
38
+ axios.get.mockResolvedValue({ status: 200, data: { prompts: [{ id: 'p' }] } });
39
+ const idx = await fetchIndex();
40
+ expect(idx).toEqual({ prompts: [{ id: 'p' }] });
41
+ // disk cache should exist
42
+ const { DISK_CACHE_FILE } = diskPaths();
43
+ expect(await fs.pathExists(DISK_CACHE_FILE)).toBe(true);
44
+ });
45
+
46
+ test('uses disk cache when fresh', async () => {
47
+ // write fresh disk cache
48
+ const payload = { prompts: [{ id: 'p2' }] };
49
+ const { DISK_CACHE_DIR, DISK_CACHE_FILE } = diskPaths();
50
+ await fs.ensureDir(DISK_CACHE_DIR);
51
+ await fs.writeJson(DISK_CACHE_FILE, { ts: Date.now(), payload });
52
+ const idx = await fetchIndex();
53
+ expect(idx).toEqual(payload);
54
+ // axios should not have been called
55
+ expect(axios.get).not.toHaveBeenCalled();
56
+ });
57
+
58
+ test('falls back to stale disk cache if network fails', async () => {
59
+ // write stale disk cache (old ts)
60
+ const payload = { prompts: [{ id: 'stale' }] };
61
+ const { DISK_CACHE_DIR, DISK_CACHE_FILE } = diskPaths();
62
+ await fs.ensureDir(DISK_CACHE_DIR);
63
+ await fs.writeJson(DISK_CACHE_FILE, { ts: Date.now() - (60 * 60 * 1000), payload });
64
+ axios.get.mockRejectedValue(new Error('network error'));
65
+ const idx = await fetchIndex();
66
+ expect(idx).toEqual(payload);
67
+ });
68
+
69
+ test('builds combined index and detects conflicts across repos', async () => {
70
+ // configure two repos that both contain a 'shared' prompt id
71
+ process.env.ACP_REPOS_JSON = JSON.stringify([
72
+ { id: 'r1', treeUrl: 'https://api/repo1', rawBase: 'https://raw1' },
73
+ { id: 'r2', treeUrl: 'https://api/repo2', rawBase: 'https://raw2' }
74
+ ]);
75
+
76
+ axios.get.mockImplementation((url) => {
77
+ // repo trees
78
+ if (url === 'https://api/repo1') {
79
+ return Promise.resolve({ status: 200, data: { tree: [
80
+ { path: 'prompts/p1.prompt.md', type: 'blob' },
81
+ { path: 'prompts/shared.prompt.md', type: 'blob' }
82
+ ] } });
83
+ }
84
+ if (url === 'https://api/repo2') {
85
+ return Promise.resolve({ status: 200, data: { tree: [
86
+ { path: 'prompts/p2.prompt.md', type: 'blob' },
87
+ { path: 'prompts/shared.prompt.md', type: 'blob' }
88
+ ] } });
89
+ }
90
+ // per-file raw content
91
+ if (url.startsWith('https://raw1/')) {
92
+ return Promise.resolve({ status: 200, data: '---\ntitle: "Repo One P1"\n---\ncontent' });
93
+ }
94
+ if (url.startsWith('https://raw2/')) {
95
+ return Promise.resolve({ status: 200, data: '---\ntitle: "Repo Two P2"\n---\ncontent' });
96
+ }
97
+ return Promise.reject(new Error('unexpected url ' + url));
98
+ });
99
+
100
+ const idx = await fetchIndex();
101
+
102
+ // repos metadata should be present
103
+ expect(idx._repos).toEqual([
104
+ { id: 'r1', treeUrl: 'https://api/repo1', rawBase: 'https://raw1' },
105
+ { id: 'r2', treeUrl: 'https://api/repo2', rawBase: 'https://raw2' }
106
+ ]);
107
+
108
+ // prompts should include items from both repos
109
+ const pids = idx.prompts.map(p => `${p.repo}:${p.id}`).sort();
110
+ expect(pids).toEqual(expect.arrayContaining(['r1:p1', 'r2:p2', 'r1:shared', 'r2:shared']));
111
+
112
+ // conflict should include 'shared'
113
+ expect(idx._conflicts).toContain('shared');
114
+
115
+ // shared items should have repo metadata and be two entries
116
+ const shared = idx.prompts.filter(p => p.id === 'shared');
117
+ expect(shared.length).toBe(2);
118
+ expect(new Set(shared.map(s => s.repo))).toEqual(new Set(['r1','r2']));
119
+
120
+ delete process.env.ACP_REPOS_JSON;
121
+ });
122
+ });
@@ -0,0 +1,14 @@
1
+ const { performInstall } = require('../src/commands/install');
2
+ const cache = require('../src/cache');
3
+
4
+ test('performInstall errors on ambiguous names', async () => {
5
+ cache.set('index', {
6
+ prompts: [ { id: 'abc', name: 'ABC', repo: 'r1' }, { id: 'abcd', name: 'ABCD', repo: 'r2' } ],
7
+ chatmodes: [],
8
+ instructions: []
9
+ });
10
+ // ambiguous prefix 'ab' matches multiple entries and should cause an error (exitCode 2)
11
+ await performInstall({ target: 'workspace', type: 'prompts', names: ['ab'], options: {} });
12
+ expect(process.exitCode).toBe(2);
13
+ cache.del('index');
14
+ });
@@ -0,0 +1,37 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const axios = require('axios');
5
+ jest.mock('axios');
6
+
7
+ let performInstall;
8
+ const cache = require('../src/cache');
9
+
10
+ beforeEach(() => {
11
+ cache.del('index');
12
+ delete process.env.ACP_REPOS_JSON;
13
+ });
14
+
15
+ test('performInstall resolves repo-qualified names and installs only matching repo item (dry-run)', async () => {
16
+ // build an index in ACP_INDEX_JSON for simplicity
17
+ const idx = {
18
+ prompts: [
19
+ { id: 'shared', name: 'Shared One', repo: 'r1', url: null, content: 'x' },
20
+ { id: 'shared', name: 'Shared Two', repo: 'r2', url: null, content: 'y' }
21
+ ],
22
+ chatmodes: [],
23
+ instructions: []
24
+ };
25
+ process.env.ACP_INDEX_JSON = JSON.stringify(idx);
26
+ ({ performInstall } = require('../src/commands/install'));
27
+
28
+ // performInstall in package-mode with repo-qualified name
29
+ const tmp = path.join(os.tmpdir(), `acp-install-cmd-${Date.now()}`);
30
+ await fs.ensureDir(tmp);
31
+
32
+ // Use dry-run so no files are written, but ensure resolution finds correct item
33
+ await performInstall({ target: 'r1:shared', names: [], options: { 'dry-run': true }, workspaceDir: tmp });
34
+
35
+ // cleanup env
36
+ delete process.env.ACP_INDEX_JSON;
37
+ });
@@ -0,0 +1,40 @@
1
+ jest.mock('../src/fetcher');
2
+ const { fetchIndex } = require('../src/fetcher');
3
+ const cache = require('../src/cache');
4
+ const { performInstall } = require('../src/commands/install');
5
+ const fs = require('fs-extra');
6
+
7
+ const FIXTURE_INDEX = {
8
+ prompts: [{ id: 'p1', name: 'Prompt One' }],
9
+ chatmodes: [{ id: 'c1', name: 'Chat Mode One' }],
10
+ instructions: [{ id: 'i1', name: 'Instruction One' }]
11
+ };
12
+
13
+ beforeEach(() => {
14
+ fetchIndex.mockResolvedValue(FIXTURE_INDEX);
15
+ cache.del('index');
16
+ });
17
+
18
+ test('performInstall installs a named package when present', async () => {
19
+ // create a temp dir to act as workspace
20
+ const tmp = require('path').join(require('os').tmpdir(), `acp-test-${Date.now()}`);
21
+ await fs.ensureDir(tmp);
22
+ await performInstall({ target: 'p1', type: undefined, names: [], options: { verbose: false }, workspaceDir: tmp });
23
+ // performInstall returns undefined but should have written files under tmp/.github or prompts
24
+ const gh = require('path').join(tmp, '.github');
25
+ const exists = await fs.pathExists(gh);
26
+ expect(exists).toBe(true);
27
+ });
28
+
29
+ test('performInstall errors when package not found', async () => {
30
+ // capture console.error
31
+ const errs = [];
32
+ const orig = console.error;
33
+ console.error = (...args) => errs.push(args.join(' '));
34
+ try {
35
+ await performInstall({ target: 'nonexistent-package', type: undefined, names: [], options: {} });
36
+ expect(errs.some(e => e.includes('not found'))).toBeTruthy();
37
+ } finally {
38
+ console.error = orig;
39
+ }
40
+ });
@@ -0,0 +1,50 @@
1
+ jest.mock('prompts');
2
+ const prompts = require('prompts');
3
+ const cache = require('../src/cache');
4
+
5
+ afterEach(() => {
6
+ cache.del('index');
7
+ delete process.env.ACP_INDEX_JSON;
8
+ // restore TTY flag
9
+ if (process.stdin && typeof process.stdin.isTTY !== 'undefined') process.stdin.isTTY = false;
10
+ });
11
+
12
+ test('performInstall package-mode when package not found sets exitCode 2', async () => {
13
+ const { performInstall } = require('../src/commands/install');
14
+ // ensure cache empty
15
+ cache.del('index');
16
+ // call with a package name that does not exist
17
+ await performInstall({ target: 'no-such-package', options: {} });
18
+ expect(process.exitCode).toBe(2);
19
+ });
20
+
21
+ test('performInstall interactive prompt skip path logs skipped by user', async () => {
22
+ // simulate interactive TTY
23
+ if (process.stdin) process.stdin.isTTY = true;
24
+ // mock prompts to respond with ok: false
25
+ prompts.mockResolvedValue({ ok: false });
26
+ process.env.ACP_INDEX_JSON = JSON.stringify({ prompts: [{ id: 'p1', name: 'One', repo: 'r1' }], chatmodes: [], instructions: [] });
27
+ const { performInstall } = require('../src/commands/install');
28
+ const logs = [];
29
+ jest.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
30
+ await performInstall({ target: 'workspace', type: 'prompts', names: [], options: {} });
31
+ expect(logs.some(l => l.includes('Skipped by user'))).toBe(true);
32
+ console.log.mockRestore();
33
+ });
34
+
35
+ test('performInstall handles fetchIndex throwing by setting exitCode 2', async () => {
36
+ // mock fetcher to throw
37
+ jest.resetModules();
38
+ jest.doMock('../src/fetcher', () => ({
39
+ fetchIndex: async () => { throw new Error('network'); },
40
+ diskPaths: () => ({ DISK_CACHE_FILE: '/nonexistent' })
41
+ }));
42
+ const { performInstall } = require('../src/commands/install');
43
+ // ensure cache cleared
44
+ cache.del('index');
45
+ await performInstall({ target: 'workspace', type: 'prompts', names: [], options: {} });
46
+ expect(process.exitCode).toBe(2);
47
+ // cleanup mocked module
48
+ jest.dontMock('../src/fetcher');
49
+ jest.resetModules();
50
+ });
@@ -0,0 +1,35 @@
1
+ jest.mock('../src/fetcher');
2
+ const { fetchIndex } = require('../src/fetcher');
3
+ const cache = require('../src/cache');
4
+ const { performInstall } = require('../src/commands/install');
5
+
6
+ beforeEach(() => {
7
+ cache.del('index');
8
+ });
9
+
10
+ test('performInstall handles multiple names (dry-run) and reports both', async () => {
11
+ const FIXTURE_INDEX = {
12
+ prompts: [
13
+ { id: 'p1', name: 'Prompt One' },
14
+ { id: 'p2', name: 'Prompt Two' }
15
+ ],
16
+ chatmodes: [],
17
+ instructions: []
18
+ };
19
+ fetchIndex.mockResolvedValue(FIXTURE_INDEX);
20
+
21
+ const logs = [];
22
+ const origLog = console.log;
23
+ console.log = (...args) => logs.push(args.join(' '));
24
+ try {
25
+ await performInstall({ target: 'workspace', type: 'prompts', names: ['p1', 'p2'], options: { 'dry-run': true } });
26
+ // Find dry-run line and ensure both prompts are listed
27
+ const dryRunLines = logs.filter(l => l.includes('[dry-run]'));
28
+ expect(dryRunLines.length).toBeGreaterThan(0);
29
+ const listed = logs.filter(l => l.includes('Prompt One') || l.includes('Prompt Two'));
30
+ expect(listed.some(l => l.includes('Prompt One'))).toBeTruthy();
31
+ expect(listed.some(l => l.includes('Prompt Two'))).toBeTruthy();
32
+ } finally {
33
+ console.log = origLog;
34
+ }
35
+ });
@@ -0,0 +1,22 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { installFiles } = require('../src/installer');
5
+
6
+ test('install preserves repo id in filenames and writes workspace files for multi-repo items', async () => {
7
+ const tmp = path.join(os.tmpdir(), `acp-install-multi-${Date.now()}`);
8
+ await fs.ensureDir(tmp);
9
+ const items = [
10
+ { id: 'one', name: 'One', content: 'a', repo: 'r1' },
11
+ { id: 'one', name: 'One from r2', content: 'b', repo: 'r2' }
12
+ ];
13
+ const dest = await installFiles({ items, type: 'prompts', target: 'workspace', workspaceDir: tmp });
14
+ expect(await fs.pathExists(dest)).toBe(true);
15
+ const files = await fs.readdir(dest);
16
+ // Should have written two files for both items
17
+ expect(files.length).toBe(2);
18
+ // Read files and ensure contents correspond
19
+ const contents = await Promise.all(files.map(f => fs.readFile(path.join(dest, f), 'utf8')));
20
+ expect(contents.some(c => c.includes('a'))).toBe(true);
21
+ expect(contents.some(c => c.includes('b'))).toBe(true);
22
+ });
@@ -0,0 +1,62 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const axios = require('axios');
5
+ jest.mock('axios');
6
+
7
+ const { installFiles, removeFiles } = require('../src/installer');
8
+ const { performInstall } = require('../src/commands/install');
9
+ const cache = require('../src/cache');
10
+
11
+ test('installFiles fetches raw content when item.url is present and writes workspace file', async () => {
12
+ const tmp = path.join(os.tmpdir(), `acp-install-raw-${Date.now()}`);
13
+ await fs.ensureDir(tmp);
14
+ axios.get.mockResolvedValueOnce({ data: 'RAW-CONTENT' });
15
+ const items = [{ id: 'raw1', name: 'Raw One', url: 'https://raw.example/raw1' }];
16
+ const dest = await installFiles({ items, type: 'prompts', target: 'workspace', workspaceDir: tmp });
17
+ const files = await fs.readdir(dest);
18
+ expect(files.length).toBe(1);
19
+ const content = await fs.readFile(path.join(dest, files[0]), 'utf8');
20
+ expect(content).toBe('RAW-CONTENT');
21
+ });
22
+
23
+ test('installFiles writes prompts to user prompts folder', async () => {
24
+ const tmp = path.join(os.tmpdir(), `acp-installer-user-prompts-${Date.now()}`);
25
+ await fs.ensureDir(tmp);
26
+ const origHome = process.env.HOME;
27
+ process.env.HOME = tmp;
28
+
29
+ const items = [{ id: 'up1', name: 'User Prompt', content: 'u' }];
30
+ const dest = await installFiles({ items, type: 'prompts', target: 'user' });
31
+ expect(await fs.pathExists(dest)).toBe(true);
32
+ const files = await fs.readdir(dest);
33
+ expect(files.length).toBeGreaterThan(0);
34
+
35
+ if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
36
+ });
37
+
38
+ test('performInstall resolves names across types when multiple types and uses dry-run', async () => {
39
+ // build index with same id in prompts and chatmodes
40
+ cache.set('index', {
41
+ prompts: [{ id: 'multi', name: 'Multi Prompt', repo: 'r1' }],
42
+ chatmodes: [{ id: 'multi', name: 'Multi Chat', repo: 'r2' }],
43
+ instructions: []
44
+ });
45
+ const logs = [];
46
+ jest.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
47
+ await performInstall({ target: 'workspace', type: 'all', names: ['multi'], options: { 'dry-run': true } });
48
+ // dry-run should report two installs (one prompts and one chatmodes)
49
+ expect(logs.some(l => l.includes('Would install')) || logs.some(l => l.includes('[dry-run]'))).toBe(true);
50
+ console.log.mockRestore();
51
+ cache.del('index');
52
+ });
53
+
54
+ test('removeFiles can remove files that are not JSON by matching filename', async () => {
55
+ const tmp = path.join(os.tmpdir(), `acp-uninstall-nonjson-${Date.now()}`);
56
+ const base = path.join(tmp, '.github', 'prompts');
57
+ await fs.ensureDir(base);
58
+ const fname = path.join(base, 'plainfile.prompt.md');
59
+ await fs.writeFile(fname, 'plain text not json');
60
+ const removed = await removeFiles({ names: ['plainfile.prompt.md'], type: 'prompts', target: 'workspace', workspaceDir: tmp });
61
+ expect(removed).toBe(1);
62
+ });
@@ -0,0 +1,19 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { installFiles } = require('../src/installer');
5
+
6
+ test('install writes non-prompts to user .github folder', async () => {
7
+ const tmp = path.join(os.tmpdir(), `acp-installer-user-${Date.now()}`);
8
+ await fs.ensureDir(tmp);
9
+ const origHome = process.env.HOME;
10
+ process.env.HOME = tmp;
11
+
12
+ const items = [{ id: 'c1', name: 'ChatMode', content: 'x' }];
13
+ const dest = await installFiles({ items, type: 'chatmodes', target: 'user' });
14
+ expect(await fs.pathExists(dest)).toBe(true);
15
+ const files = await fs.readdir(dest);
16
+ expect(files.length).toBeGreaterThan(0);
17
+
18
+ if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
19
+ });
@@ -0,0 +1,14 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { installFiles } = require('../src/installer');
5
+
6
+ test('install to workspace', async () => {
7
+ const tmp = path.join(os.tmpdir(), `acp-test-${Date.now()}`);
8
+ await fs.ensureDir(tmp);
9
+ const items = [{ id: 'one', name: 'One', content: { body: 'x' } }];
10
+ const dest = await installFiles({ items, type: 'prompts', target: 'workspace', workspaceDir: tmp });
11
+ expect(await fs.pathExists(dest)).toBe(true);
12
+ const files = await fs.readdir(dest);
13
+ expect(files.length).toBeGreaterThan(0);
14
+ });
@@ -0,0 +1,31 @@
1
+ const { formatListLines } = require('../src/commands/list');
2
+
3
+ describe('formatListLines', () => {
4
+ test('produces header, delimiter and aligned rows', () => {
5
+ const items = [
6
+ { type: 'prompt', id: 'p1', name: 'Short' },
7
+ { type: 'chatmode', id: 'longer-id', name: 'A longer name' },
8
+ { type: 'instruction', id: 'i', name: '' }
9
+ ];
10
+ const lines = formatListLines(items);
11
+ // Should have header and delimiter as first two lines
12
+ expect(lines.length).toBeGreaterThanOrEqual(5);
13
+ const header = lines[0];
14
+ const delim = lines[1];
15
+ expect(header).toMatch(/Type\s+ID\s+Name/);
16
+ expect(delim).toMatch(/^-+\s+-+\s+-+/);
17
+
18
+ // All subsequent rows should have the same column boundaries as header
19
+ const headerCols = header.split(/\s{2,}/);
20
+ const colWidths = headerCols.map(c => c.length);
21
+
22
+ for (let i = 2; i < lines.length; i++) {
23
+ const cols = lines[i].split(/\s{2,}/);
24
+ expect(cols.length).toBe(3);
25
+ // each column should be at least as wide as header's column
26
+ for (let j = 0; j < 3; j++) {
27
+ expect(cols[j].length).toBeGreaterThanOrEqual(colWidths[j]);
28
+ }
29
+ }
30
+ });
31
+ });
@@ -0,0 +1,15 @@
1
+ const { listItems, formatListLines } = require('../src/commands/list');
2
+
3
+ test('listItems includes repo prefix for conflicted ids and formatListLines emits header', () => {
4
+ const idx = {
5
+ prompts: [ { id: 'shared', name: 'Shared Prompt', repo: 'r1' } ],
6
+ chatmodes: [],
7
+ instructions: [],
8
+ _conflicts: ['shared']
9
+ };
10
+ const items = listItems(idx, 'prompts');
11
+ expect(items.length).toBe(1);
12
+ expect(items[0].id).toBe('r1:shared');
13
+ const lines = formatListLines(items);
14
+ expect(lines[0]).toMatch(/Type\s+ID\s+Name/);
15
+ });
@@ -0,0 +1,35 @@
1
+ const cache = require('../src/cache');
2
+
3
+ function makeCli() {
4
+ const cli = {
5
+ _action: null,
6
+ command() { return cli; },
7
+ option() { return cli; },
8
+ action(fn) { cli._action = fn; return cli; }
9
+ };
10
+ return cli;
11
+ }
12
+
13
+ describe('list JSON output', () => {
14
+ afterEach(() => {
15
+ cache.del('index');
16
+ delete process.env.ACP_INDEX_JSON;
17
+ });
18
+
19
+ test('listCommand emits valid JSON when --json is passed', async () => {
20
+ process.env.ACP_INDEX_JSON = JSON.stringify({ prompts: [{ id: 'p1', name: 'Prompt One', repo: 'r1' }], chatmodes: [], instructions: [], _conflicts: [] });
21
+ const stubCli = makeCli();
22
+ const lc = require('../src/commands/list');
23
+ lc.listCommand(stubCli);
24
+ const logs = [];
25
+ jest.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
26
+ await stubCli._action('prompts', { refresh: true, verbose: false, json: true });
27
+ expect(logs.length).toBeGreaterThan(0);
28
+ // The first log should be parseable JSON representing an array
29
+ const parsed = JSON.parse(logs.join('\n'));
30
+ expect(Array.isArray(parsed)).toBe(true);
31
+ expect(parsed.length).toBe(1);
32
+ expect(parsed[0].id).toBe('p1');
33
+ console.log.mockRestore();
34
+ });
35
+ });
@@ -0,0 +1,14 @@
1
+ const { searchIndex } = require('../src/commands/search');
2
+
3
+ test('searchIndex returns results with repo-qualified ids when conflicts exist', () => {
4
+ const idx = {
5
+ prompts: [ { id: 'shared', name: 'Shared Prompt', repo: 'r1' } ],
6
+ chatmodes: [ { id: 'other', name: 'Other Mode', repo: 'r2' } ],
7
+ instructions: [],
8
+ _conflicts: ['shared']
9
+ };
10
+ const res = searchIndex(idx, 'shared');
11
+ expect(res.length).toBe(1);
12
+ expect(res[0].id).toBe('r1:shared');
13
+ expect(res[0].type).toBe('prompt');
14
+ });
@@ -0,0 +1,34 @@
1
+ const cache = require('../src/cache');
2
+
3
+ function makeCli() {
4
+ const cli = {
5
+ _action: null,
6
+ command() { return cli; },
7
+ option() { return cli; },
8
+ action(fn) { cli._action = fn; return cli; }
9
+ };
10
+ return cli;
11
+ }
12
+
13
+ describe('search JSON output', () => {
14
+ afterEach(() => {
15
+ cache.del('index');
16
+ delete process.env.ACP_INDEX_JSON;
17
+ });
18
+
19
+ test('searchCommand emits valid JSON when --json is passed', async () => {
20
+ process.env.ACP_INDEX_JSON = JSON.stringify({ prompts: [{ id: 's1', name: 'SearchMe', repo: 'r1' }], chatmodes: [], instructions: [], _conflicts: [] });
21
+ const stubCli = makeCli();
22
+ const sc = require('../src/commands/search');
23
+ sc.searchCommand(stubCli);
24
+ const logs = [];
25
+ jest.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
26
+ await stubCli._action('searchme', { refresh: true, verbose: false, json: true });
27
+ expect(logs.length).toBeGreaterThan(0);
28
+ const parsed = JSON.parse(logs.join('\n'));
29
+ expect(Array.isArray(parsed)).toBe(true);
30
+ expect(parsed.length).toBe(1);
31
+ expect(parsed[0].id).toBe('s1');
32
+ console.log.mockRestore();
33
+ });
34
+ });
@@ -0,0 +1,52 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { removeFiles } = require('../src/installer');
5
+
6
+ test('removeFiles removes repo-prefixed files in workspace by repo:id and id', async () => {
7
+ const tmp = path.join(os.tmpdir(), `acp-uninstall-prefixed-${Date.now()}`);
8
+ const base = path.join(tmp, '.github', 'prompts');
9
+ await fs.ensureDir(base);
10
+ // simulate files written with repo prefix
11
+ const f1 = path.join(base, 'r1-shared.prompt.md');
12
+ const f2 = path.join(base, 'r2-shared.prompt.md');
13
+ await fs.writeFile(f1, JSON.stringify({ id: 'shared', repo: 'r1' }));
14
+ await fs.writeFile(f2, JSON.stringify({ id: 'shared', repo: 'r2' }));
15
+
16
+ // Remove r1:shared should remove only r1 file
17
+ let removed = await removeFiles({ names: ['r1:shared'], type: 'prompts', target: 'workspace', workspaceDir: tmp });
18
+ expect(removed).toBe(1);
19
+ expect(await fs.pathExists(f1)).toBe(false);
20
+ expect(await fs.pathExists(f2)).toBe(true);
21
+
22
+ // Remove remaining by id-only should remove the other
23
+ removed = await removeFiles({ names: ['shared'], type: 'prompts', target: 'workspace', workspaceDir: tmp });
24
+ expect(removed).toBe(1);
25
+ expect(await fs.pathExists(f2)).toBe(false);
26
+ });
27
+
28
+ test('removeFiles removes repo-prefixed files in user dir by repo:id and id', async () => {
29
+ // For user path use getVsCodeUserDir location but override HOME via env to a tmp dir
30
+ const tmp = path.join(os.tmpdir(), `acp-uninstall-prefixed-user-${Date.now()}`);
31
+ await fs.ensureDir(tmp);
32
+ // set HOME so getVsCodeUserDir resolves under tmp
33
+ const origHome = process.env.HOME;
34
+ process.env.HOME = tmp;
35
+ const userPrompts = path.join(tmp, 'Library', 'Application Support', 'Code', 'User', 'prompts');
36
+ await fs.ensureDir(userPrompts);
37
+ const f1 = path.join(userPrompts, 'r1-shared.prompt.md');
38
+ const f2 = path.join(userPrompts, 'r2-shared.prompt.md');
39
+ await fs.writeFile(f1, JSON.stringify({ id: 'shared', repo: 'r1' }));
40
+ await fs.writeFile(f2, JSON.stringify({ id: 'shared', repo: 'r2' }));
41
+
42
+ let removed = await removeFiles({ names: ['r2:shared'], type: 'prompts', target: 'user' });
43
+ expect(removed).toBe(1);
44
+ expect(await fs.pathExists(f2)).toBe(false);
45
+
46
+ removed = await removeFiles({ names: ['shared'], type: 'prompts', target: 'user' });
47
+ expect(removed).toBe(1);
48
+ expect(await fs.pathExists(f1)).toBe(false);
49
+
50
+ // restore HOME
51
+ if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
52
+ });
@@ -0,0 +1,21 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { removeFiles } = require('../src/installer');
5
+
6
+ test('removeFiles removes named files in workspace', async () => {
7
+ const tmp = path.join(os.tmpdir(), `acp-uninstall-${Date.now()}`);
8
+ const base = path.join(tmp, '.github', 'prompts');
9
+ await fs.ensureDir(base);
10
+ const f1 = path.join(base, 'one.json');
11
+ const f2 = path.join(base, 'two.json');
12
+ await fs.writeJson(f1, { id: 'one', name: 'One' });
13
+ await fs.writeJson(f2, { id: 'two', name: 'Two' });
14
+
15
+ const removed = await removeFiles({ names: ['one'], type: 'prompts', target: 'workspace', workspaceDir: tmp });
16
+ expect(removed).toBe(1);
17
+ const exists1 = await fs.pathExists(f1);
18
+ const exists2 = await fs.pathExists(f2);
19
+ expect(exists1).toBe(false);
20
+ expect(exists2).toBe(true);
21
+ });