@pokelo/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/README.md +121 -0
- package/bin/pokelo.mjs +8 -0
- package/package.json +33 -0
- package/skill/SKILL.md +98 -0
- package/skill/reference.md +59 -0
- package/skill/scripts/ensure-pokelo.sh +47 -0
- package/src/cli.mjs +256 -0
- package/src/commands/assets.mjs +283 -0
- package/src/commands/auth.mjs +109 -0
- package/src/commands/docs.mjs +131 -0
- package/src/commands/projects.mjs +35 -0
- package/src/commands/search.mjs +36 -0
- package/src/config.mjs +148 -0
- package/src/exit.mjs +13 -0
- package/src/http.mjs +71 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { fail } from './exit.mjs';
|
|
6
|
+
|
|
7
|
+
const LEGACY_CONFIG_PATH = join(homedir(), '.pokelo-cli.json');
|
|
8
|
+
|
|
9
|
+
/** @typedef {{ url: string, token: string, defaultProjectId?: string | null }} PokeloConfig */
|
|
10
|
+
|
|
11
|
+
export function xdgConfigPath() {
|
|
12
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
13
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
|
|
14
|
+
return join(base, 'pokelo', 'config.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize instance URL to API base ending with /v1 (no trailing slash beyond that).
|
|
19
|
+
* Accepts https://host, https://host/, https://host/v1, https://host/v1/.
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeApiBaseUrl(raw) {
|
|
22
|
+
const trimmed = String(raw ?? '').trim().replace(/\/+$/, '');
|
|
23
|
+
if (!trimmed) fail('url is required');
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
|
27
|
+
} catch {
|
|
28
|
+
fail(`invalid url: ${raw}`);
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
31
|
+
fail(`url must be http(s): ${raw}`);
|
|
32
|
+
}
|
|
33
|
+
let path = url.pathname.replace(/\/+$/, '') || '';
|
|
34
|
+
if (path === '' || path === '/') {
|
|
35
|
+
path = '/v1';
|
|
36
|
+
} else if (!path.endsWith('/v1')) {
|
|
37
|
+
path = `${path}/v1`.replace(/\/+/g, '/');
|
|
38
|
+
}
|
|
39
|
+
return `${url.origin}${path}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonFile(path) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(path, 'utf8');
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Load persisted config (XDG first, then legacy ~/.pokelo-cli.json). */
|
|
52
|
+
export function loadPersistedConfig() {
|
|
53
|
+
const xdg = readJsonFile(xdgConfigPath());
|
|
54
|
+
if (xdg && typeof xdg === 'object') return sanitizeFileConfig(xdg);
|
|
55
|
+
const legacy = readJsonFile(LEGACY_CONFIG_PATH);
|
|
56
|
+
if (legacy && typeof legacy === 'object') {
|
|
57
|
+
return sanitizeFileConfig(migrateLegacy(legacy));
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function migrateLegacy(legacy) {
|
|
63
|
+
const url = typeof legacy.url === 'string' ? legacy.url : '';
|
|
64
|
+
// Old PoC stored MCP URL (.../v1/mcp) — strip /mcp for REST base.
|
|
65
|
+
const cleaned = url.replace(/\/mcp\/?$/i, '');
|
|
66
|
+
return {
|
|
67
|
+
url: cleaned,
|
|
68
|
+
token: legacy.token,
|
|
69
|
+
defaultProjectId: legacy.defaultProjectId ?? null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sanitizeFileConfig(obj) {
|
|
74
|
+
const url = typeof obj.url === 'string' ? obj.url.trim() : '';
|
|
75
|
+
const token = typeof obj.token === 'string' ? obj.token.trim() : '';
|
|
76
|
+
const defaultProjectId =
|
|
77
|
+
typeof obj.defaultProjectId === 'string' && obj.defaultProjectId.trim()
|
|
78
|
+
? obj.defaultProjectId.trim()
|
|
79
|
+
: null;
|
|
80
|
+
if (!url && !token) return null;
|
|
81
|
+
return { url, token, defaultProjectId };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve runtime config.
|
|
86
|
+
* @param {{ url?: string, token?: string, project?: string }} flags
|
|
87
|
+
*/
|
|
88
|
+
export function resolveConfig(flags = {}) {
|
|
89
|
+
const file = loadPersistedConfig();
|
|
90
|
+
const urlRaw =
|
|
91
|
+
(typeof flags.url === 'string' && flags.url.trim()) ||
|
|
92
|
+
process.env.POKELO_URL?.trim() ||
|
|
93
|
+
file?.url ||
|
|
94
|
+
'';
|
|
95
|
+
const token =
|
|
96
|
+
(typeof flags.token === 'string' && flags.token.trim()) ||
|
|
97
|
+
process.env.POKELO_TOKEN?.trim() ||
|
|
98
|
+
file?.token ||
|
|
99
|
+
'';
|
|
100
|
+
const project =
|
|
101
|
+
(typeof flags.project === 'string' && flags.project.trim()) ||
|
|
102
|
+
process.env.POKELO_PROJECT?.trim() ||
|
|
103
|
+
file?.defaultProjectId ||
|
|
104
|
+
null;
|
|
105
|
+
|
|
106
|
+
if (!urlRaw) fail('missing Pokelo URL (set --url, POKELO_URL, or run: pokelo auth login)');
|
|
107
|
+
if (!token) fail('missing access token (set --token, POKELO_TOKEN, or run: pokelo auth login)');
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
url: normalizeApiBaseUrl(urlRaw),
|
|
111
|
+
token,
|
|
112
|
+
defaultProjectId: project,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Persist config to XDG path. Never logs the token.
|
|
118
|
+
* @param {{ url: string, token: string, defaultProjectId?: string | null }} cfg
|
|
119
|
+
*/
|
|
120
|
+
export function saveConfig(cfg) {
|
|
121
|
+
const path = xdgConfigPath();
|
|
122
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
123
|
+
const payload = {
|
|
124
|
+
url: normalizeApiBaseUrl(cfg.url),
|
|
125
|
+
token: String(cfg.token).trim(),
|
|
126
|
+
defaultProjectId: cfg.defaultProjectId?.trim() || null,
|
|
127
|
+
};
|
|
128
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
129
|
+
try {
|
|
130
|
+
chmodSync(path, 0o600);
|
|
131
|
+
} catch {
|
|
132
|
+
/* best-effort on platforms without chmod */
|
|
133
|
+
}
|
|
134
|
+
return path;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function maskToken(token) {
|
|
138
|
+
const t = String(token ?? '');
|
|
139
|
+
if (!t) return null;
|
|
140
|
+
if (t.length <= 8) return `${t.slice(0, 2)}…`;
|
|
141
|
+
return `${t.slice(0, 6)}…${t.slice(-4)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function configExists() {
|
|
145
|
+
return existsSync(xdgConfigPath()) || existsSync(LEGACY_CONFIG_PATH);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { LEGACY_CONFIG_PATH };
|
package/src/exit.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Usage / config errors. */
|
|
2
|
+
export const EXIT_USAGE = 1;
|
|
3
|
+
/** HTTP / API errors. */
|
|
4
|
+
export const EXIT_API = 2;
|
|
5
|
+
|
|
6
|
+
export function fail(message, code = EXIT_USAGE) {
|
|
7
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
8
|
+
process.exit(code);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function writeJson(data) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
13
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { EXIT_API, fail } from './exit.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {import('./config.mjs').PokeloConfig} config
|
|
5
|
+
* @param {string} method
|
|
6
|
+
* @param {string} path - path under /v1, e.g. "/projects" or "projects"
|
|
7
|
+
* @param {{ body?: unknown, query?: Record<string, string | number | undefined | null> }} [opts]
|
|
8
|
+
*/
|
|
9
|
+
export async function apiRequest(config, method, path, opts = {}) {
|
|
10
|
+
const rel = path.startsWith('/') ? path : `/${path}`;
|
|
11
|
+
const url = new URL(`${config.url}${rel}`);
|
|
12
|
+
if (opts.query) {
|
|
13
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
14
|
+
if (v === undefined || v === null || v === '') continue;
|
|
15
|
+
url.searchParams.set(k, String(v));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(url, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${config.token}`,
|
|
25
|
+
Accept: 'application/json',
|
|
26
|
+
...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
27
|
+
},
|
|
28
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
29
|
+
});
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
32
|
+
fail(`network request failed: ${msg}`, EXIT_API);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (res.status === 204) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const text = await res.text().catch(() => '');
|
|
40
|
+
let json = null;
|
|
41
|
+
if (text) {
|
|
42
|
+
try {
|
|
43
|
+
json = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
json = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (json && typeof json === 'object' && 'success' in json) {
|
|
50
|
+
if (json.success === false) {
|
|
51
|
+
const err = json.error;
|
|
52
|
+
const message =
|
|
53
|
+
typeof err?.message === 'string'
|
|
54
|
+
? err.message
|
|
55
|
+
: Array.isArray(err?.message)
|
|
56
|
+
? err.message.join('; ')
|
|
57
|
+
: err?.code
|
|
58
|
+
? String(err.code)
|
|
59
|
+
: `HTTP ${res.status}`;
|
|
60
|
+
fail(message, EXIT_API);
|
|
61
|
+
}
|
|
62
|
+
return json.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const snippet = text && text.length < 500 ? text : `HTTP ${res.status} ${res.statusText}`;
|
|
67
|
+
fail(snippet, EXIT_API);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return json ?? text;
|
|
71
|
+
}
|