@researchcomputer/pista 0.1.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.
@@ -0,0 +1,21 @@
1
+ export function getTranscriptWindow(totalEntries, requestedVisibleCount, requestedOffset) {
2
+ const visibleCount = Math.max(1, requestedVisibleCount);
3
+ const maxOffset = Math.max(0, totalEntries - visibleCount);
4
+ const offset = clamp(requestedOffset, 0, maxOffset);
5
+ const start = Math.max(0, totalEntries - visibleCount - offset);
6
+ const end = Math.min(totalEntries, start + visibleCount);
7
+ return { start, end, visibleCount, offset, maxOffset };
8
+ }
9
+ export function stepTranscriptOffset(currentOffset, delta, totalEntries, visibleCount) {
10
+ const window = getTranscriptWindow(totalEntries, visibleCount, currentOffset);
11
+ return clamp(window.offset + delta, 0, window.maxOffset);
12
+ }
13
+ export function getTranscriptOffsetForEntry(totalEntries, requestedVisibleCount, entryIndex) {
14
+ const visibleCount = Math.max(1, requestedVisibleCount);
15
+ const maxOffset = Math.max(0, totalEntries - visibleCount);
16
+ const centeredStart = clamp(entryIndex - Math.floor(visibleCount / 2), 0, Math.max(0, totalEntries - visibleCount));
17
+ return clamp(totalEntries - visibleCount - centeredStart, 0, maxOffset);
18
+ }
19
+ function clamp(value, min, max) {
20
+ return Math.min(max, Math.max(min, value));
21
+ }
@@ -0,0 +1,33 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { getTranscriptOffsetForEntry, getTranscriptWindow, stepTranscriptOffset } from './transcript.js';
4
+ test('getTranscriptWindow clamps the offset to available history', () => {
5
+ const result = getTranscriptWindow(10, 4, 99);
6
+ assert.deepEqual(result, {
7
+ start: 0,
8
+ end: 4,
9
+ visibleCount: 4,
10
+ offset: 6,
11
+ maxOffset: 6,
12
+ });
13
+ });
14
+ test('getTranscriptWindow returns the latest page when offset is zero', () => {
15
+ const result = getTranscriptWindow(10, 4, 0);
16
+ assert.deepEqual(result, {
17
+ start: 6,
18
+ end: 10,
19
+ visibleCount: 4,
20
+ offset: 0,
21
+ maxOffset: 6,
22
+ });
23
+ });
24
+ test('stepTranscriptOffset moves within the valid bounds', () => {
25
+ assert.equal(stepTranscriptOffset(0, 3, 20, 5), 3);
26
+ assert.equal(stepTranscriptOffset(3, -10, 20, 5), 0);
27
+ assert.equal(stepTranscriptOffset(12, 20, 20, 5), 15);
28
+ });
29
+ test('getTranscriptOffsetForEntry centers the requested entry when possible', () => {
30
+ assert.equal(getTranscriptOffsetForEntry(20, 5, 10), 7);
31
+ assert.equal(getTranscriptOffsetForEntry(20, 5, 0), 15);
32
+ assert.equal(getTranscriptOffsetForEntry(20, 5, 19), 0);
33
+ });
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,219 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ function ensureDir(dirPath) {
5
+ fs.mkdirSync(dirPath, { recursive: true });
6
+ }
7
+ export function getCliDataDir(baseDir = os.homedir()) {
8
+ return path.join(baseDir, '.pista');
9
+ }
10
+ export function getCliSessionsDir(baseDir = os.homedir()) {
11
+ return path.join(getCliDataDir(baseDir), 'sessions');
12
+ }
13
+ export function getCliMemoryDir(baseDir = os.homedir()) {
14
+ return path.join(getCliDataDir(baseDir), 'memory');
15
+ }
16
+ export function getCliPreferencesPath(baseDir = os.homedir()) {
17
+ return path.join(getCliDataDir(baseDir), 'preferences.json');
18
+ }
19
+ export function getApiKeyPath() {
20
+ return path.join(os.homedir(), '.pista-api-key');
21
+ }
22
+ export function getStoredApiKey() {
23
+ try {
24
+ const filePath = getApiKeyPath();
25
+ if (fs.existsSync(filePath)) {
26
+ return fs.readFileSync(filePath, 'utf8').trim();
27
+ }
28
+ }
29
+ catch {
30
+ // Ignore errors
31
+ }
32
+ return undefined;
33
+ }
34
+ export function saveApiKey(key) {
35
+ fs.writeFileSync(getApiKeyPath(), key, { mode: 0o600 });
36
+ }
37
+ export function loadStoredPreferences(baseDir = os.homedir()) {
38
+ try {
39
+ const filePath = getCliPreferencesPath(baseDir);
40
+ if (!fs.existsSync(filePath)) {
41
+ return {};
42
+ }
43
+ const raw = fs.readFileSync(filePath, 'utf8');
44
+ const parsed = JSON.parse(raw);
45
+ return typeof parsed === 'object' && parsed ? parsed : {};
46
+ }
47
+ catch {
48
+ return {};
49
+ }
50
+ }
51
+ export function saveStoredPreferences(preferences, baseDir = os.homedir()) {
52
+ const dataDir = getCliDataDir(baseDir);
53
+ ensureDir(dataDir);
54
+ fs.writeFileSync(getCliPreferencesPath(baseDir), `${JSON.stringify(preferences, null, 2)}\n`, { mode: 0o600 });
55
+ }
56
+ export function mergeStoredPreferences(base, override) {
57
+ const skills = mergeStoredSkills(base.skills, override.skills);
58
+ return {
59
+ selection: {
60
+ ...(base.selection ?? {}),
61
+ ...(override.selection ?? {}),
62
+ },
63
+ ...(skills ? { skills } : {}),
64
+ };
65
+ }
66
+ export function loadResolvedPreferences(projectDir, globalBaseDir = os.homedir()) {
67
+ const globalPreferences = loadStoredPreferences(globalBaseDir);
68
+ if (!projectDir) {
69
+ return globalPreferences;
70
+ }
71
+ const projectPreferences = loadStoredPreferences(projectDir);
72
+ return mergeStoredPreferences(globalPreferences, projectPreferences);
73
+ }
74
+ export function mergeStoredSkills(base = [], override = []) {
75
+ if (base.length === 0 && override.length === 0) {
76
+ return undefined;
77
+ }
78
+ const orderedIds = [];
79
+ const merged = new Map();
80
+ for (const skill of [...base, ...override]) {
81
+ if (!merged.has(skill.id)) {
82
+ orderedIds.push(skill.id);
83
+ }
84
+ merged.set(skill.id, skill);
85
+ }
86
+ return orderedIds.map((id) => merged.get(id));
87
+ }
88
+ export function resolveConfiguredSkills(skills = []) {
89
+ return skills
90
+ .filter((skill) => skill.enabled !== false)
91
+ .map(({ enabled: _enabled, ...skill }) => skill);
92
+ }
93
+ export function truncate(value, maxLength) {
94
+ if (value.length <= maxLength)
95
+ return value;
96
+ if (maxLength <= 3)
97
+ return value.slice(0, maxLength);
98
+ return `${value.slice(0, maxLength - 3)}...`;
99
+ }
100
+ export function stringifyPreview(value, maxLength) {
101
+ if (value === undefined)
102
+ return '[no content]';
103
+ try {
104
+ return truncate(JSON.stringify(value, null, 2), maxLength);
105
+ }
106
+ catch {
107
+ return truncate(String(value), maxLength);
108
+ }
109
+ }
110
+ export function logColor(kind) {
111
+ switch (kind) {
112
+ case 'system':
113
+ return 'blueBright';
114
+ case 'user':
115
+ return 'greenBright';
116
+ case 'assistant':
117
+ return 'cyanBright';
118
+ case 'tool':
119
+ return 'yellowBright';
120
+ case 'error':
121
+ return 'redBright';
122
+ }
123
+ }
124
+ export function errorMessage(error) {
125
+ if (error instanceof Error)
126
+ return error.message;
127
+ return String(error);
128
+ }
129
+ /** Parse YAML frontmatter from a SKILL.md file. */
130
+ export function parseSkillFrontmatter(content) {
131
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
132
+ if (!match)
133
+ return null;
134
+ const frontmatter = match[1];
135
+ const body = match[2].trim();
136
+ let name = '';
137
+ let description = '';
138
+ for (const line of frontmatter.split('\n')) {
139
+ const nameMatch = line.match(/^name:\s*(.+)/);
140
+ if (nameMatch)
141
+ name = nameMatch[1].trim().replace(/^["']|["']$/g, '');
142
+ const descMatch = line.match(/^description:\s*(.+)/);
143
+ if (descMatch)
144
+ description = descMatch[1].trim().replace(/^["']|["']$/g, '');
145
+ }
146
+ if (!name)
147
+ return null;
148
+ return { name, description, body };
149
+ }
150
+ /**
151
+ * Fetch a skill from GitHub. Accepts formats:
152
+ * owner/repo — looks for SKILL.md at repo root
153
+ * owner/repo/path/to/skill — looks for SKILL.md in that directory
154
+ * https://github.com/owner/repo/... — full URL, extracts path
155
+ */
156
+ export async function fetchGitHubSkill(source) {
157
+ let owner;
158
+ let repo;
159
+ let skillPath;
160
+ const urlMatch = source.match(/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/[^/]+\/(.+)|\/(.+))?$/);
161
+ if (urlMatch) {
162
+ owner = urlMatch[1];
163
+ repo = urlMatch[2].replace(/\.git$/, '');
164
+ skillPath = urlMatch[3] || urlMatch[4] || '';
165
+ }
166
+ else {
167
+ const parts = source.split('/');
168
+ if (parts.length < 2)
169
+ throw new Error(`Invalid GitHub reference: ${source}. Use owner/repo or owner/repo/path.`);
170
+ owner = parts[0];
171
+ repo = parts[1];
172
+ skillPath = parts.slice(2).join('/');
173
+ }
174
+ const basePath = skillPath ? `${skillPath}/SKILL.md` : 'SKILL.md';
175
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/${basePath}`;
176
+ const response = await fetch(rawUrl);
177
+ if (!response.ok) {
178
+ if (response.status === 404) {
179
+ throw new Error(`SKILL.md not found at ${owner}/${repo}/${basePath}. Make sure the path contains a SKILL.md file.`);
180
+ }
181
+ throw new Error(`Failed to fetch SKILL.md: HTTP ${response.status}`);
182
+ }
183
+ const content = await response.text();
184
+ const parsed = parseSkillFrontmatter(content);
185
+ if (!parsed) {
186
+ throw new Error('Could not parse SKILL.md frontmatter. Expected YAML frontmatter with at least a "name" field.');
187
+ }
188
+ const skill = {
189
+ id: parsed.name,
190
+ description: parsed.description || undefined,
191
+ promptSections: parsed.body ? [parsed.body] : [],
192
+ metadata: {
193
+ source: `github:${owner}/${repo}${skillPath ? '/' + skillPath : ''}`,
194
+ },
195
+ };
196
+ return skill;
197
+ }
198
+ /**
199
+ * Fetch the skill index from a GitHub repo's .claude-plugin/marketplace.json.
200
+ * Returns available skill paths, or null if no marketplace manifest exists.
201
+ */
202
+ export async function fetchGitHubSkillIndex(owner, repo) {
203
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
204
+ const response = await fetch(rawUrl);
205
+ if (!response.ok)
206
+ return null;
207
+ const data = await response.json();
208
+ return data.plugins ?? null;
209
+ }
210
+ export function normalizeEndpoint(value) {
211
+ const parsed = new URL(value);
212
+ return parsed.toString().replace(/\/+$/, '');
213
+ }
214
+ export function normalizeAgentName(value) {
215
+ const collapsed = value.trim().replace(/\s+/g, ' ');
216
+ if (!collapsed)
217
+ return 'Pista';
218
+ return collapsed.slice(0, 24);
219
+ }
@@ -0,0 +1,97 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { getCliMemoryDir, getCliPreferencesPath, getCliSessionsDir, loadResolvedPreferences, loadStoredPreferences, mergeStoredSkills, mergeStoredPreferences, normalizeAgentName, resolveConfiguredSkills, saveStoredPreferences, } from './utils.js';
7
+ test('CLI home-directory helpers resolve under the provided base directory', () => {
8
+ const baseDir = '/tmp/pi-agent-home';
9
+ assert.equal(getCliSessionsDir(baseDir), path.join(baseDir, '.pista', 'sessions'));
10
+ assert.equal(getCliMemoryDir(baseDir), path.join(baseDir, '.pista', 'memory'));
11
+ assert.equal(getCliPreferencesPath(baseDir), path.join(baseDir, '.pista', 'preferences.json'));
12
+ });
13
+ test('stored preferences round-trip through the preferences file', () => {
14
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pista-cli-'));
15
+ saveStoredPreferences({
16
+ selection: {
17
+ endpoint: 'https://example.invalid/v1',
18
+ model: 'gpt-test',
19
+ apiStyle: 'responses',
20
+ },
21
+ }, tempDir);
22
+ assert.deepEqual(loadStoredPreferences(tempDir), {
23
+ selection: {
24
+ endpoint: 'https://example.invalid/v1',
25
+ model: 'gpt-test',
26
+ apiStyle: 'responses',
27
+ },
28
+ });
29
+ });
30
+ test('mergeStoredPreferences applies project overrides field-by-field', () => {
31
+ assert.deepEqual(mergeStoredPreferences({
32
+ selection: { model: 'global-model', endpoint: 'https://global.invalid/v1', apiStyle: 'chat' },
33
+ skills: [{ id: 'global-skill', promptSections: ['Global prompt'] }],
34
+ }, {
35
+ selection: { model: 'project-model' },
36
+ skills: [{ id: 'project-skill', promptSections: ['Project prompt'] }],
37
+ }), {
38
+ selection: { model: 'project-model', endpoint: 'https://global.invalid/v1', apiStyle: 'chat' },
39
+ skills: [
40
+ { id: 'global-skill', promptSections: ['Global prompt'] },
41
+ { id: 'project-skill', promptSections: ['Project prompt'] },
42
+ ],
43
+ });
44
+ });
45
+ test('loadResolvedPreferences layers project preferences over global preferences', () => {
46
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pista-home-'));
47
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pista-project-'));
48
+ saveStoredPreferences({
49
+ selection: {
50
+ model: 'global-model',
51
+ endpoint: 'https://global.invalid/v1',
52
+ apiStyle: 'chat',
53
+ },
54
+ }, homeDir);
55
+ saveStoredPreferences({
56
+ selection: {
57
+ model: 'project-model',
58
+ },
59
+ skills: [{
60
+ id: 'project-skill',
61
+ promptSections: ['Prefer concise summaries.'],
62
+ }],
63
+ }, projectDir);
64
+ assert.deepEqual(loadResolvedPreferences(projectDir, homeDir), {
65
+ selection: {
66
+ model: 'project-model',
67
+ endpoint: 'https://global.invalid/v1',
68
+ apiStyle: 'chat',
69
+ },
70
+ skills: [{
71
+ id: 'project-skill',
72
+ promptSections: ['Prefer concise summaries.'],
73
+ }],
74
+ });
75
+ });
76
+ test('mergeStoredSkills replaces matching ids and preserves order', () => {
77
+ assert.deepEqual(mergeStoredSkills([
78
+ { id: 'shared', promptSections: ['Global shared'] },
79
+ { id: 'global-only', promptSections: ['Global only'] },
80
+ ], [
81
+ { id: 'shared', promptSections: ['Project shared'] },
82
+ { id: 'project-only', promptSections: ['Project only'] },
83
+ ]), [
84
+ { id: 'shared', promptSections: ['Project shared'] },
85
+ { id: 'global-only', promptSections: ['Global only'] },
86
+ { id: 'project-only', promptSections: ['Project only'] },
87
+ ]);
88
+ });
89
+ test('resolveConfiguredSkills filters disabled skills and strips CLI-only fields', () => {
90
+ assert.deepEqual(resolveConfiguredSkills([
91
+ { id: 'enabled', promptSections: ['Use TypeScript.'] },
92
+ { id: 'disabled', promptSections: ['Do not include me.'], enabled: false },
93
+ ]), [{ id: 'enabled', promptSections: ['Use TypeScript.'] }]);
94
+ });
95
+ test('normalizeAgentName falls back to Pista when given blank input', () => {
96
+ assert.equal(normalizeAgentName(' '), 'Pista');
97
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@researchcomputer/pista",
3
+ "version": "0.1.2",
4
+ "description": "A lightweight CLI agent interface built with Ink",
5
+ "type": "module",
6
+ "bin": {
7
+ "pista": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "start": "node dist/index.js",
15
+ "prepublishOnly": "npm run build",
16
+ "test": "npm run build && node --test dist/*.test.js dist/**/*.test.js"
17
+ },
18
+ "dependencies": {
19
+ "@researchcomputer/agents-sdk": "^0.1.0",
20
+ "@types/node": "^25.5.0",
21
+ "ink": "^6.8.0",
22
+ "react": "^19.2.4"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.2.14",
26
+ "typescript": "^5.8.0"
27
+ },
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/researchcomputer/agents.git",
32
+ "directory": "pista"
33
+ }
34
+ }