@picahq/cli 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { initCommand } from './commands/init.js';
5
+ import { connectionAddCommand, connectionListCommand } from './commands/connection.js';
6
+ import { platformsCommand } from './commands/platforms.js';
7
+ import { actionsSearchCommand, actionsKnowledgeCommand, actionsExecuteCommand } from './commands/actions.js';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('pica')
13
+ .description('CLI for managing Pica integrations')
14
+ .version('0.1.0');
15
+
16
+ program
17
+ .command('init')
18
+ .description('Set up Pica and install MCP to your AI agents')
19
+ .option('-y, --yes', 'Skip confirmations')
20
+ .option('-g, --global', 'Install MCP globally (available in all projects)')
21
+ .option('-p, --project', 'Install MCP for this project only (creates .mcp.json)')
22
+ .action(async (options) => {
23
+ await initCommand(options);
24
+ });
25
+
26
+ const connection = program
27
+ .command('connection')
28
+ .description('Manage connections');
29
+
30
+ connection
31
+ .command('add [platform]')
32
+ .alias('a')
33
+ .description('Add a new connection')
34
+ .action(async (platform) => {
35
+ await connectionAddCommand(platform);
36
+ });
37
+
38
+ connection
39
+ .command('list')
40
+ .alias('ls')
41
+ .description('List your connections')
42
+ .action(async () => {
43
+ await connectionListCommand();
44
+ });
45
+
46
+ program
47
+ .command('platforms')
48
+ .alias('p')
49
+ .description('List available platforms')
50
+ .option('-c, --category <category>', 'Filter by category')
51
+ .option('--json', 'Output as JSON')
52
+ .action(async (options) => {
53
+ await platformsCommand(options);
54
+ });
55
+
56
+ // Shortcuts
57
+ program
58
+ .command('add [platform]')
59
+ .description('Shortcut for: connection add')
60
+ .action(async (platform) => {
61
+ await connectionAddCommand(platform);
62
+ });
63
+
64
+ program
65
+ .command('list')
66
+ .alias('ls')
67
+ .description('Shortcut for: connection list')
68
+ .action(async () => {
69
+ await connectionListCommand();
70
+ });
71
+
72
+ // Actions command group
73
+ const actions = program
74
+ .command('actions')
75
+ .alias('a')
76
+ .description('Discover and execute platform actions');
77
+
78
+ actions
79
+ .command('search <platform> [query]')
80
+ .description('Search actions on a platform')
81
+ .option('--json', 'Output as JSON')
82
+ .option('-l, --limit <limit>', 'Max results', '10')
83
+ .action(async (platform: string, query: string | undefined, options: { json?: boolean; limit?: string }) => {
84
+ await actionsSearchCommand(platform, query, options);
85
+ });
86
+
87
+ actions
88
+ .command('knowledge <actionId>')
89
+ .alias('k')
90
+ .description('Get API docs for an action')
91
+ .option('--json', 'Output as JSON')
92
+ .option('--full', 'Show full knowledge (no truncation)')
93
+ .action(async (actionId: string, options: { json?: boolean; full?: boolean }) => {
94
+ await actionsKnowledgeCommand(actionId, options);
95
+ });
96
+
97
+ actions
98
+ .command('execute <actionId>')
99
+ .alias('x')
100
+ .description('Execute an action')
101
+ .option('-c, --connection <key>', 'Connection key to use')
102
+ .option('-d, --data <json>', 'Request body as JSON')
103
+ .option('-p, --path-var <key=value...>', 'Path variable', collectValues)
104
+ .option('-q, --query <key=value...>', 'Query parameter', collectValues)
105
+ .option('--form-data', 'Send as multipart/form-data')
106
+ .option('--form-urlencoded', 'Send as application/x-www-form-urlencoded')
107
+ .option('--json', 'Output as JSON')
108
+ .action(async (actionId: string, options) => {
109
+ await actionsExecuteCommand(actionId, options);
110
+ });
111
+
112
+ // Top-level shortcuts
113
+ program
114
+ .command('search <platform> [query]')
115
+ .description('Shortcut for: actions search')
116
+ .option('--json', 'Output as JSON')
117
+ .option('-l, --limit <limit>', 'Max results', '10')
118
+ .action(async (platform: string, query: string | undefined, options: { json?: boolean; limit?: string }) => {
119
+ await actionsSearchCommand(platform, query, options);
120
+ });
121
+
122
+ program
123
+ .command('exec <actionId>')
124
+ .description('Shortcut for: actions execute')
125
+ .option('-c, --connection <key>', 'Connection key to use')
126
+ .option('-d, --data <json>', 'Request body as JSON')
127
+ .option('-p, --path-var <key=value...>', 'Path variable', collectValues)
128
+ .option('-q, --query <key=value...>', 'Query parameter', collectValues)
129
+ .option('--form-data', 'Send as multipart/form-data')
130
+ .option('--form-urlencoded', 'Send as application/x-www-form-urlencoded')
131
+ .option('--json', 'Output as JSON')
132
+ .action(async (actionId: string, options) => {
133
+ await actionsExecuteCommand(actionId, options);
134
+ });
135
+
136
+ function collectValues(value: string, previous: string[]): string[] {
137
+ return (previous || []).concat([value]);
138
+ }
139
+
140
+ program.parse();
@@ -0,0 +1,59 @@
1
+ const ACTION_ID_PREFIX = 'conn_mod_def::';
2
+
3
+ /**
4
+ * Ensure action ID has the required prefix.
5
+ */
6
+ export function normalizeActionId(id: string): string {
7
+ if (id.startsWith(ACTION_ID_PREFIX)) return id;
8
+ return `${ACTION_ID_PREFIX}${id}`;
9
+ }
10
+
11
+ /**
12
+ * Extract {{variable}} names from a path string.
13
+ */
14
+ export function extractPathVariables(path: string): string[] {
15
+ const matches = path.match(/\{\{(\w+)\}\}/g);
16
+ if (!matches) return [];
17
+ return matches.map(m => m.replace(/\{\{|\}\}/g, ''));
18
+ }
19
+
20
+ /**
21
+ * Replace {{variable}} placeholders in a path with provided values.
22
+ */
23
+ export function replacePathVariables(
24
+ path: string,
25
+ vars: Record<string, string>
26
+ ): string {
27
+ let result = path;
28
+ for (const [key, value] of Object.entries(vars)) {
29
+ result = result.replace(`{{${key}}}`, encodeURIComponent(value));
30
+ }
31
+ return result;
32
+ }
33
+
34
+ /**
35
+ * Resolve path variables from data object and pathVars overrides.
36
+ * Returns the resolved path and the remaining data (with used keys removed).
37
+ */
38
+ export function resolveTemplateVariables(
39
+ path: string,
40
+ data: Record<string, unknown>,
41
+ pathVars: Record<string, string>
42
+ ): { resolvedPath: string; remainingData: Record<string, unknown> } {
43
+ const variables = extractPathVariables(path);
44
+ const merged: Record<string, string> = { ...pathVars };
45
+ const remaining = { ...data };
46
+
47
+ // Fill from data if not already provided in pathVars
48
+ for (const v of variables) {
49
+ if (!merged[v] && data[v] != null) {
50
+ merged[v] = String(data[v]);
51
+ delete remaining[v];
52
+ }
53
+ }
54
+
55
+ return {
56
+ resolvedPath: replacePathVariables(path, merged),
57
+ remainingData: remaining,
58
+ };
59
+ }
@@ -0,0 +1,191 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import type { Agent } from './types.js';
5
+
6
+ export type InstallScope = 'global' | 'project';
7
+
8
+ function expandPath(p: string): string {
9
+ if (p.startsWith('~/')) {
10
+ return path.join(os.homedir(), p.slice(2));
11
+ }
12
+ return p;
13
+ }
14
+
15
+ function getClaudeDesktopConfigPath(): string {
16
+ switch (process.platform) {
17
+ case 'darwin':
18
+ return '~/Library/Application Support/Claude/claude_desktop_config.json';
19
+ case 'win32':
20
+ return path.join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json');
21
+ default:
22
+ return '~/.config/Claude/claude_desktop_config.json';
23
+ }
24
+ }
25
+
26
+ function getClaudeDesktopDetectDir(): string {
27
+ switch (process.platform) {
28
+ case 'darwin':
29
+ return '~/Library/Application Support/Claude';
30
+ case 'win32':
31
+ return path.join(process.env.APPDATA || '', 'Claude');
32
+ default:
33
+ return '~/.config/Claude';
34
+ }
35
+ }
36
+
37
+ function getWindsurfConfigPath(): string {
38
+ if (process.platform === 'win32') {
39
+ return path.join(process.env.USERPROFILE || os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
40
+ }
41
+ return '~/.codeium/windsurf/mcp_config.json';
42
+ }
43
+
44
+ function getWindsurfDetectDir(): string {
45
+ if (process.platform === 'win32') {
46
+ return path.join(process.env.USERPROFILE || os.homedir(), '.codeium', 'windsurf');
47
+ }
48
+ return '~/.codeium/windsurf';
49
+ }
50
+
51
+ function getCursorConfigPath(): string {
52
+ if (process.platform === 'win32') {
53
+ return path.join(process.env.USERPROFILE || os.homedir(), '.cursor', 'mcp.json');
54
+ }
55
+ return '~/.cursor/mcp.json';
56
+ }
57
+
58
+ const AGENTS: Agent[] = [
59
+ {
60
+ id: 'claude-code',
61
+ name: 'Claude Code',
62
+ configPath: '~/.claude.json',
63
+ configKey: 'mcpServers',
64
+ detectDir: '~/.claude',
65
+ projectConfigPath: '.mcp.json',
66
+ },
67
+ {
68
+ id: 'claude-desktop',
69
+ name: 'Claude Desktop',
70
+ configPath: getClaudeDesktopConfigPath(),
71
+ configKey: 'mcpServers',
72
+ detectDir: getClaudeDesktopDetectDir(),
73
+ },
74
+ {
75
+ id: 'cursor',
76
+ name: 'Cursor',
77
+ configPath: getCursorConfigPath(),
78
+ configKey: 'mcpServers',
79
+ detectDir: '~/.cursor',
80
+ projectConfigPath: '.cursor/mcp.json',
81
+ },
82
+ {
83
+ id: 'windsurf',
84
+ name: 'Windsurf',
85
+ configPath: getWindsurfConfigPath(),
86
+ configKey: 'mcpServers',
87
+ detectDir: getWindsurfDetectDir(),
88
+ },
89
+ ];
90
+
91
+ export function getAllAgents(): Agent[] {
92
+ return AGENTS;
93
+ }
94
+
95
+ export function detectInstalledAgents(): Agent[] {
96
+ return AGENTS.filter(agent => {
97
+ const detectDir = expandPath(agent.detectDir);
98
+ return fs.existsSync(detectDir);
99
+ });
100
+ }
101
+
102
+ export function supportsProjectScope(agent: Agent): boolean {
103
+ return agent.projectConfigPath !== undefined;
104
+ }
105
+
106
+ export function getAgentConfigPath(agent: Agent, scope: InstallScope = 'global'): string {
107
+ if (scope === 'project' && agent.projectConfigPath) {
108
+ return path.join(process.cwd(), agent.projectConfigPath);
109
+ }
110
+ return expandPath(agent.configPath);
111
+ }
112
+
113
+ export function readAgentConfig(agent: Agent, scope: InstallScope = 'global'): Record<string, unknown> {
114
+ const configPath = getAgentConfigPath(agent, scope);
115
+ if (!fs.existsSync(configPath)) {
116
+ return {};
117
+ }
118
+ try {
119
+ const content = fs.readFileSync(configPath, 'utf-8');
120
+ return JSON.parse(content);
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ export function writeAgentConfig(agent: Agent, config: Record<string, unknown>, scope: InstallScope = 'global'): void {
127
+ const configPath = getAgentConfigPath(agent, scope);
128
+ const configDir = path.dirname(configPath);
129
+
130
+ if (!fs.existsSync(configDir)) {
131
+ fs.mkdirSync(configDir, { recursive: true });
132
+ }
133
+
134
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
135
+ }
136
+
137
+ export function getMcpServerConfig(apiKey: string): Record<string, unknown> {
138
+ return {
139
+ command: 'npx',
140
+ args: ['-y', '@picahq/mcp'],
141
+ env: {
142
+ PICA_SECRET: apiKey,
143
+ },
144
+ };
145
+ }
146
+
147
+ export function installMcpConfig(agent: Agent, apiKey: string, scope: InstallScope = 'global'): void {
148
+ const config = readAgentConfig(agent, scope);
149
+ const configKey = agent.configKey;
150
+
151
+ const mcpServers = (config[configKey] as Record<string, unknown>) || {};
152
+ mcpServers['pica'] = getMcpServerConfig(apiKey);
153
+
154
+ config[configKey] = mcpServers;
155
+ writeAgentConfig(agent, config, scope);
156
+ }
157
+
158
+ export function isMcpInstalled(agent: Agent, scope: InstallScope = 'global'): boolean {
159
+ const config = readAgentConfig(agent, scope);
160
+ const configKey = agent.configKey;
161
+ const mcpServers = config[configKey] as Record<string, unknown> | undefined;
162
+ return mcpServers?.['pica'] !== undefined;
163
+ }
164
+
165
+ export function getProjectConfigPaths(agents: Agent[]): string[] {
166
+ const paths: string[] = [];
167
+ for (const agent of agents) {
168
+ if (agent.projectConfigPath) {
169
+ paths.push(path.join(process.cwd(), agent.projectConfigPath));
170
+ }
171
+ }
172
+ return paths;
173
+ }
174
+
175
+ export interface AgentStatus {
176
+ agent: Agent;
177
+ detected: boolean;
178
+ globalMcp: boolean;
179
+ projectMcp: boolean | null; // null = agent doesn't support project scope
180
+ }
181
+
182
+ export function getAgentStatuses(): AgentStatus[] {
183
+ return AGENTS.map(agent => {
184
+ const detected = fs.existsSync(expandPath(agent.detectDir));
185
+ const globalMcp = detected && isMcpInstalled(agent, 'global');
186
+ const projectMcp = agent.projectConfigPath
187
+ ? isMcpInstalled(agent, 'project')
188
+ : null;
189
+ return { agent, detected, globalMcp, projectMcp };
190
+ });
191
+ }
package/src/lib/api.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type {
2
+ Connection,
3
+ ConnectionsResponse,
4
+ Platform,
5
+ PlatformsResponse,
6
+ PlatformAction,
7
+ ActionsSearchResponse,
8
+ ActionKnowledge,
9
+ KnowledgeResponse,
10
+ } from './types.js';
11
+ import { normalizeActionId } from './actions.js';
12
+
13
+ const API_BASE = 'https://api.picaos.com/v1';
14
+
15
+ export class ApiError extends Error {
16
+ constructor(public status: number, message: string) {
17
+ super(message);
18
+ this.name = 'ApiError';
19
+ }
20
+ }
21
+
22
+ export class PicaApi {
23
+ constructor(private apiKey: string) {}
24
+
25
+ private async request<T>(path: string): Promise<T> {
26
+ return this.requestFull<T>({ path });
27
+ }
28
+
29
+ private async requestFull<T>(opts: {
30
+ path: string;
31
+ method?: string;
32
+ body?: unknown;
33
+ headers?: Record<string, string>;
34
+ queryParams?: Record<string, string>;
35
+ }): Promise<T> {
36
+ let url = `${API_BASE}${opts.path}`;
37
+ if (opts.queryParams && Object.keys(opts.queryParams).length > 0) {
38
+ const params = new URLSearchParams(opts.queryParams);
39
+ url += `?${params.toString()}`;
40
+ }
41
+
42
+ const headers: Record<string, string> = {
43
+ 'x-pica-secret': this.apiKey,
44
+ 'Content-Type': 'application/json',
45
+ ...opts.headers,
46
+ };
47
+
48
+ const fetchOpts: RequestInit = {
49
+ method: opts.method || 'GET',
50
+ headers,
51
+ };
52
+
53
+ if (opts.body !== undefined) {
54
+ fetchOpts.body = JSON.stringify(opts.body);
55
+ }
56
+
57
+ const response = await fetch(url, fetchOpts);
58
+
59
+ if (!response.ok) {
60
+ const text = await response.text();
61
+ throw new ApiError(response.status, text || `HTTP ${response.status}`);
62
+ }
63
+
64
+ return response.json() as Promise<T>;
65
+ }
66
+
67
+ async validateApiKey(): Promise<boolean> {
68
+ try {
69
+ await this.listConnections();
70
+ return true;
71
+ } catch (error) {
72
+ if (error instanceof ApiError && error.status === 401) {
73
+ return false;
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ async listConnections(): Promise<Connection[]> {
80
+ const response = await this.request<ConnectionsResponse>('/vault/connections');
81
+ return response.rows || [];
82
+ }
83
+
84
+ async listPlatforms(): Promise<Platform[]> {
85
+ const allPlatforms: Platform[] = [];
86
+ let page = 1;
87
+ let totalPages = 1;
88
+
89
+ do {
90
+ const response = await this.request<PlatformsResponse>(`/available-connectors?page=${page}&limit=100`);
91
+ allPlatforms.push(...(response.rows || []));
92
+ totalPages = response.pages || 1;
93
+ page++;
94
+ } while (page <= totalPages);
95
+
96
+ return allPlatforms;
97
+ }
98
+
99
+ async searchActions(platform: string, query?: string, limit = 10): Promise<PlatformAction[]> {
100
+ const queryParams: Record<string, string> = {
101
+ limit: String(limit),
102
+ executeAgent: 'true',
103
+ };
104
+ if (query) queryParams.query = query;
105
+
106
+ const response = await this.requestFull<ActionsSearchResponse>({
107
+ path: `/available-actions/search/${encodeURIComponent(platform)}`,
108
+ queryParams,
109
+ });
110
+ return response.rows || [];
111
+ }
112
+
113
+ async getActionKnowledge(actionId: string): Promise<ActionKnowledge | null> {
114
+ const normalized = normalizeActionId(actionId);
115
+ const response = await this.requestFull<KnowledgeResponse>({
116
+ path: '/knowledge',
117
+ queryParams: { _id: normalized },
118
+ });
119
+ return response.rows?.[0] ?? null;
120
+ }
121
+
122
+ async executeAction(opts: {
123
+ method: string;
124
+ path: string;
125
+ actionId: string;
126
+ connectionKey: string;
127
+ data?: unknown;
128
+ queryParams?: Record<string, string>;
129
+ headers?: Record<string, string>;
130
+ isFormData?: boolean;
131
+ isFormUrlEncoded?: boolean;
132
+ }): Promise<unknown> {
133
+ const headers: Record<string, string> = {
134
+ 'x-pica-connection-key': opts.connectionKey,
135
+ 'x-pica-action-id': normalizeActionId(opts.actionId),
136
+ ...opts.headers,
137
+ };
138
+
139
+ if (opts.isFormData) {
140
+ headers['Content-Type'] = 'multipart/form-data';
141
+ } else if (opts.isFormUrlEncoded) {
142
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
143
+ }
144
+
145
+ return this.requestFull<unknown>({
146
+ path: `/passthrough${opts.path}`,
147
+ method: opts.method.toUpperCase(),
148
+ body: opts.data,
149
+ headers,
150
+ queryParams: opts.queryParams,
151
+ });
152
+ }
153
+
154
+ async waitForConnection(
155
+ platform: string,
156
+ timeoutMs = 5 * 60 * 1000,
157
+ pollIntervalMs = 5000,
158
+ onPoll?: () => void
159
+ ): Promise<Connection> {
160
+ const startTime = Date.now();
161
+ const existingConnections = await this.listConnections();
162
+ const existingIds = new Set(existingConnections.map(c => c.id));
163
+
164
+ while (Date.now() - startTime < timeoutMs) {
165
+ await sleep(pollIntervalMs);
166
+ onPoll?.();
167
+
168
+ const currentConnections = await this.listConnections();
169
+ const newConnection = currentConnections.find(
170
+ c => c.platform.toLowerCase() === platform.toLowerCase() && !existingIds.has(c.id)
171
+ );
172
+
173
+ if (newConnection) {
174
+ return newConnection;
175
+ }
176
+ }
177
+
178
+ throw new TimeoutError(`Timed out waiting for ${platform} connection`);
179
+ }
180
+ }
181
+
182
+ export class TimeoutError extends Error {
183
+ constructor(message: string) {
184
+ super(message);
185
+ this.name = 'TimeoutError';
186
+ }
187
+ }
188
+
189
+ function sleep(ms: number): Promise<void> {
190
+ return new Promise(resolve => setTimeout(resolve, ms));
191
+ }
@@ -0,0 +1,20 @@
1
+ import open from 'open';
2
+
3
+ const PICA_APP_URL = 'https://app.picaos.com';
4
+
5
+ export function getConnectionUrl(platform: string): string {
6
+ return `${PICA_APP_URL}/connections?#open=${platform}`;
7
+ }
8
+
9
+ export function getApiKeyUrl(): string {
10
+ return `${PICA_APP_URL}/settings/api-keys`;
11
+ }
12
+
13
+ export async function openConnectionPage(platform: string): Promise<void> {
14
+ const url = getConnectionUrl(platform);
15
+ await open(url);
16
+ }
17
+
18
+ export async function openApiKeyPage(): Promise<void> {
19
+ await open(getApiKeyUrl());
20
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import type { Config } from './types.js';
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), '.pica');
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
+
9
+ export function getConfigPath(): string {
10
+ return CONFIG_FILE;
11
+ }
12
+
13
+ export function configExists(): boolean {
14
+ return fs.existsSync(CONFIG_FILE);
15
+ }
16
+
17
+ export function readConfig(): Config | null {
18
+ if (!configExists()) {
19
+ return null;
20
+ }
21
+ try {
22
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
23
+ return JSON.parse(content) as Config;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function writeConfig(config: Config): void {
30
+ if (!fs.existsSync(CONFIG_DIR)) {
31
+ fs.mkdirSync(CONFIG_DIR, { mode: 0o700 });
32
+ }
33
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
34
+ }
35
+
36
+ export function getApiKey(): string | null {
37
+ const config = readConfig();
38
+ return config?.apiKey ?? null;
39
+ }
40
+
41
+ export function updateInstalledAgents(agentIds: string[]): void {
42
+ const config = readConfig();
43
+ if (config) {
44
+ config.installedAgents = agentIds;
45
+ writeConfig(config);
46
+ }
47
+ }