@logicpanel/lphub 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.
Files changed (3) hide show
  1. package/README.md +153 -0
  2. package/dist/index.js +936 -0
  3. package/package.json +47 -0
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # @logicpanel/lphub
2
+
3
+ A thin command-line client for the LogicPanel Hub `/api/v1`. It authenticates with a
4
+ Personal Access Token (PAT) and forwards requests — the **API is the source of truth**.
5
+ The command tree (commands, options, help) is generated from a server **manifest**
6
+ (`GET /api/v1/cli-manifest`) and cached locally, so the CLI needs almost no maintenance:
7
+ when the API changes, you run `lphub sync`, not a rebuild.
8
+
9
+ ## Install
10
+
11
+ Requires Node.js >= 18.
12
+
13
+ ```bash
14
+ # Global install — exposes the `lphub` command
15
+ npm i -g @logicpanel/lphub
16
+ lphub --help
17
+
18
+ # …or run it without installing
19
+ npx @logicpanel/lphub --help
20
+ ```
21
+
22
+ ### From source (this monorepo)
23
+
24
+ ```bash
25
+ pnpm install # once — workspace dependencies
26
+ pnpm --filter @logicpanel/lphub build # compile → packages/lphub/dist/index.js
27
+ cd packages/lphub && pnpm link --global # expose `lphub` globally (symlink to dist/)
28
+ ```
29
+
30
+ ## First-time setup
31
+
32
+ ```bash
33
+ lphub auth login # prompts for profile name, base URL and PAT token (validates via /auth/me)
34
+ lphub sync # downloads the real manifest → ~/.lphub/manifest.json
35
+ lphub ticket list # now the real, API-driven commands work
36
+ ```
37
+
38
+ ## Updating
39
+
40
+ There are **two different updates** — don't confuse them:
41
+
42
+ | What changed | What to run |
43
+ | --- | --- |
44
+ | **The API** (new endpoint, field or filter) | `lphub sync` — no reinstall; commands are manifest-driven |
45
+ | **The CLI itself** (new release on npm) | `npm i -g @logicpanel/lphub@latest` |
46
+ | **The CLI from source** (local dev) | `pnpm --filter @logicpanel/lphub build` (the global symlink picks up the new `dist/`) |
47
+
48
+ For active development, `pnpm --filter @logicpanel/lphub dev` runs `tsup --watch`.
49
+
50
+ 90% of the time, keeping up with the backend is just `lphub sync`.
51
+
52
+ ## Uninstall
53
+
54
+ ```bash
55
+ npm uninstall -g @logicpanel/lphub # or, if linked from source:
56
+ pnpm uninstall --global @logicpanel/lphub
57
+ ```
58
+
59
+ ## Profiles & servers (prod vs local)
60
+
61
+ Each **profile** bundles a server (`baseUrl`) and its token, stored in `~/.lphub/config.json`.
62
+ Multiple servers = multiple profiles — no need to edit anything by hand.
63
+
64
+ ```bash
65
+ lphub auth login # e.g. profile "prod" → https://api-hub.hrplane.com/api/v1
66
+ lphub auth login # e.g. profile "local" → http://localhost:8484/api/v1
67
+ lphub auth list # lists profiles, marks the current one
68
+ lphub auth switch local # set the default profile
69
+ lphub --profile prod ticket list # one-off override
70
+ ```
71
+
72
+ Two optional escape hatches override the active profile's base URL (precedence:
73
+ flag > env > profile):
74
+
75
+ ```bash
76
+ lphub --base-url http://localhost:8484/api/v1 ticket list
77
+ export LPHUB_BASE_URL=http://localhost:8484/api/v1
78
+ ```
79
+
80
+ ## Directory context (`lphub init`)
81
+
82
+ Pin a profile + project to the current directory so commands auto-scope to it — like
83
+ `gh` inferring the repo from the folder. The file is **personal**, keep it out of git.
84
+
85
+ ```bash
86
+ echo .lphub.json >> .gitignore
87
+ lphub init # choose profile + project → writes .lphub.json { profile, projectId }
88
+
89
+ lphub ticket list # auto-scoped to the pinned project
90
+ lphub ticket create --title "Bug X" # project_id injected from context
91
+ lphub ticket create --title "Y" --project-id 99 # an explicit flag always wins
92
+ ```
93
+
94
+ Resolution — project: `--project-id` flag > `.lphub.json` > error if required.
95
+ Profile: `--profile` > `LPHUB_PROFILE` env > `.lphub.json` > current profile.
96
+
97
+ ## Output
98
+
99
+ The default is a human-friendly table (like `gh`). Use flags for machine-readable output:
100
+
101
+ ```bash
102
+ lphub ticket list # table
103
+ lphub ticket list --json # raw JSON
104
+ lphub ticket list --fields id,title,status,project.name # projected columns (dot-paths)
105
+ ```
106
+
107
+ ## Long / Markdown fields
108
+
109
+ For any string option you can read the value from a file or STDIN, instead of a flag
110
+ (useful for Markdown ticket descriptions — like `gh issue create --body-file`):
111
+
112
+ ```bash
113
+ lphub ticket create --title "..." --description-file ./body.md
114
+ lphub ticket create --title "..." --description-file - # read from STDIN
115
+ ```
116
+
117
+ ## Other commands
118
+
119
+ ```bash
120
+ lphub me # the authenticated user (whoami)
121
+ lphub auth status # active profile + whoami
122
+ lphub docs # list topics; `lphub docs tickets` prints a topic's examples
123
+ lphub agent setup # fetch the agent skill from the API and install it via skills.sh
124
+ ```
125
+
126
+ `lphub agent setup` downloads the SKILL.md from the API and hands it to
127
+ [`skills.sh`](https://www.skills.sh) (`npx skills add`), which installs it into your agent
128
+ (Claude Code, Cursor, …). Forward flags through, e.g. `lphub agent setup --agent claude-code -y`.
129
+
130
+ Destructive commands (HTTP `DELETE`) ask for confirmation; pass `-y`/`--yes` to skip
131
+ (also skipped automatically when not running in a TTY).
132
+
133
+ ## Exit codes
134
+
135
+ `0` success · `4` auth failure (401/403) · `1` any other error.
136
+
137
+ ## Publishing
138
+
139
+ ```bash
140
+ cd packages/lphub
141
+ npm version patch # bump the version
142
+ npm publish # `prepack` builds dist/ first; only dist/ + README ship
143
+ ```
144
+
145
+ To verify what would ship without publishing:
146
+
147
+ ```bash
148
+ npm pack --dry-run # lists the tarball contents (dist/index.js, package.json, README)
149
+ ```
150
+
151
+ The package is scoped (`@logicpanel/lphub`). To publish to a **private registry** (e.g. the
152
+ GitLab npm registry) instead of public npm, point npm at it — drop the public default with
153
+ `--access restricted` or a `.npmrc` `@logicpanel:registry=...` entry.
package/dist/index.js ADDED
@@ -0,0 +1,936 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/auth.ts
7
+ import * as readline from "readline";
8
+
9
+ // src/config.ts
10
+ import { homedir } from "os";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
+ import { join, dirname } from "path";
13
+ function getConfigPath() {
14
+ return join(homedir(), ".lphub", "config.json");
15
+ }
16
+ function getManifestPath() {
17
+ return join(homedir(), ".lphub", "manifest.json");
18
+ }
19
+ function ensureDir(p) {
20
+ mkdirSync(p, { recursive: true });
21
+ }
22
+ function loadConfig() {
23
+ const configPath = getConfigPath();
24
+ if (!existsSync(configPath)) return null;
25
+ try {
26
+ return JSON.parse(readFileSync(configPath, "utf-8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function saveConfig(config) {
32
+ const configPath = getConfigPath();
33
+ ensureDir(dirname(configPath));
34
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
35
+ }
36
+ var baseUrlOverride;
37
+ var contextProfile;
38
+ function setBaseUrlOverride(url) {
39
+ baseUrlOverride = url?.trim() || void 0;
40
+ }
41
+ function setContextProfile(name) {
42
+ contextProfile = name?.trim() || void 0;
43
+ }
44
+ function resolveBaseUrl(profile) {
45
+ return baseUrlOverride ?? process.env["LPHUB_BASE_URL"] ?? profile.baseUrl;
46
+ }
47
+ function getActiveProfile(profileOverride) {
48
+ const config = loadConfig();
49
+ if (!config) return null;
50
+ const profileName = profileOverride ?? process.env["LPHUB_PROFILE"] ?? contextProfile ?? config.currentProfile ?? "default";
51
+ const profile = config.profiles[profileName];
52
+ if (!profile) return null;
53
+ return { ...profile, baseUrl: resolveBaseUrl(profile) };
54
+ }
55
+ function saveManifest(data) {
56
+ const manifestPath = getManifestPath();
57
+ ensureDir(dirname(manifestPath));
58
+ writeFileSync(manifestPath, JSON.stringify(data, null, 2), "utf-8");
59
+ }
60
+
61
+ // src/api.ts
62
+ var ApiError = class extends Error {
63
+ constructor(status, body) {
64
+ super(`HTTP ${status}`);
65
+ this.status = status;
66
+ this.body = body;
67
+ }
68
+ };
69
+ var NetworkError = class extends Error {
70
+ constructor(origin, code) {
71
+ super(`Cannot reach ${origin}${code ? ` (${code})` : ""}`);
72
+ this.origin = origin;
73
+ this.code = code;
74
+ this.name = "NetworkError";
75
+ }
76
+ };
77
+ function exitCodeFor(status) {
78
+ return status === 401 || status === 403 ? 4 : 1;
79
+ }
80
+ async function apiRequest(profile, method, path, body, query) {
81
+ const base = profile.baseUrl.replace(/\/$/, "");
82
+ let url;
83
+ const invalidBaseUrl = () => new Error(
84
+ `Invalid base URL "${profile.baseUrl}" \u2014 must start with http:// or https:// (fix it with \`lphub auth login\` or --base-url)`
85
+ );
86
+ try {
87
+ url = new URL(`${base}${path}`);
88
+ } catch {
89
+ throw invalidBaseUrl();
90
+ }
91
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
92
+ throw invalidBaseUrl();
93
+ }
94
+ if (query) {
95
+ for (const [key, value] of Object.entries(query)) {
96
+ url.searchParams.set(key, value);
97
+ }
98
+ }
99
+ const headers = {
100
+ Authorization: `Bearer ${profile.token}`,
101
+ Accept: "application/json",
102
+ "Content-Type": "application/json"
103
+ };
104
+ let response;
105
+ try {
106
+ response = await fetch(url.toString(), {
107
+ method,
108
+ headers,
109
+ body: body !== void 0 ? JSON.stringify(body) : void 0
110
+ });
111
+ } catch (err) {
112
+ const cause = err?.cause;
113
+ const code = cause?.code ?? cause?.errors?.find((e) => e?.code)?.code;
114
+ throw new NetworkError(url.origin, code);
115
+ }
116
+ let responseBody;
117
+ try {
118
+ responseBody = await response.json();
119
+ } catch {
120
+ responseBody = await response.text().catch(() => "");
121
+ }
122
+ if (!response.ok) {
123
+ throw new ApiError(response.status, responseBody);
124
+ }
125
+ return responseBody;
126
+ }
127
+
128
+ // src/render.ts
129
+ var MAX_CELL = 40;
130
+ function isPlainObject(v) {
131
+ return typeof v === "object" && v !== null && !Array.isArray(v);
132
+ }
133
+ function isScalar(v) {
134
+ return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
135
+ }
136
+ function getPath(obj, path) {
137
+ let cur = obj;
138
+ for (const seg of path.split(".")) {
139
+ if (!isPlainObject(cur) && !Array.isArray(cur)) return void 0;
140
+ cur = cur[seg];
141
+ if (cur === void 0) return void 0;
142
+ }
143
+ return cur;
144
+ }
145
+ function objectLabel(obj) {
146
+ for (const key of ["name", "title", "full_name", "label"]) {
147
+ const v = obj[key];
148
+ if (isScalar(v) && v !== null) return String(v);
149
+ }
150
+ const id = obj["id"];
151
+ if (isScalar(id) && id !== null) return `#${String(id)}`;
152
+ return "{\u2026}";
153
+ }
154
+ function cellValue(v) {
155
+ if (v === void 0 || v === null) return "";
156
+ if (typeof v === "boolean") return v ? "true" : "false";
157
+ if (isScalar(v)) return String(v);
158
+ if (isPlainObject(v)) return objectLabel(v);
159
+ if (Array.isArray(v)) return `[${v.length}]`;
160
+ return String(v);
161
+ }
162
+ function truncate(s, max = MAX_CELL) {
163
+ if (s.length <= max) return s;
164
+ return s.slice(0, max - 1) + "\u2026";
165
+ }
166
+ function inferColumns(rows) {
167
+ const cols = [];
168
+ const seen = /* @__PURE__ */ new Set();
169
+ for (const row of rows) {
170
+ for (const [key, val] of Object.entries(row)) {
171
+ if (seen.has(key)) continue;
172
+ if (Array.isArray(val)) continue;
173
+ if (isScalar(val) || isPlainObject(val)) {
174
+ seen.add(key);
175
+ cols.push(key);
176
+ }
177
+ }
178
+ }
179
+ return cols;
180
+ }
181
+ function renderTable(headers, rows) {
182
+ const widths = headers.map(
183
+ (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
184
+ );
185
+ const fmtRow = (cells) => cells.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd();
186
+ const lines = [];
187
+ lines.push(fmtRow(headers.map((h) => h.toUpperCase())));
188
+ for (const r of rows) lines.push(fmtRow(r));
189
+ return lines.join("\n");
190
+ }
191
+ function renderKeyValue(obj) {
192
+ const keys = Object.keys(obj);
193
+ const width = Math.max(0, ...keys.map((k) => k.length));
194
+ return keys.map((k) => `${k.padEnd(width)} ${truncate(cellValue(obj[k]))}`).join("\n");
195
+ }
196
+ function projectRow(item, fields) {
197
+ return fields.map((f) => truncate(cellValue(getPath(item, f))));
198
+ }
199
+ function renderHuman(data, fields) {
200
+ let payload = data;
201
+ if (isPlainObject(data) && "data" in data) {
202
+ payload = data["data"];
203
+ }
204
+ if (Array.isArray(payload)) {
205
+ const objRows = payload.filter(isPlainObject);
206
+ if (objRows.length === 0) {
207
+ if (payload.length === 0) return "(no results)";
208
+ return payload.map((v) => cellValue(v)).join("\n");
209
+ }
210
+ const cols = fields && fields.length > 0 ? fields : inferColumns(objRows);
211
+ const rows = objRows.map(
212
+ (item) => fields && fields.length > 0 ? projectRow(item, fields) : cols.map((c) => truncate(cellValue(item[c])))
213
+ );
214
+ return renderTable(cols, rows);
215
+ }
216
+ if (isPlainObject(payload)) {
217
+ if (fields && fields.length > 0) {
218
+ const projected = {};
219
+ for (const f of fields) projected[f] = getPath(payload, f);
220
+ return renderKeyValue(projected);
221
+ }
222
+ return renderKeyValue(payload);
223
+ }
224
+ return cellValue(payload);
225
+ }
226
+
227
+ // src/output.ts
228
+ function printJson(data, raw = false) {
229
+ if (raw) {
230
+ process.stdout.write(JSON.stringify(data) + "\n");
231
+ } else {
232
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
233
+ }
234
+ }
235
+ function printResult(data, opts = {}) {
236
+ if (opts.json) {
237
+ printJson(data, true);
238
+ return;
239
+ }
240
+ process.stdout.write(renderHuman(data, opts.fields) + "\n");
241
+ }
242
+ function printError(msg, detail) {
243
+ process.stderr.write(`Error: ${msg}
244
+ `);
245
+ if (detail !== void 0) {
246
+ process.stderr.write(JSON.stringify(detail, null, 2) + "\n");
247
+ }
248
+ }
249
+ function printValidationError(body) {
250
+ if (typeof body !== "object" || body === null) return false;
251
+ const b = body;
252
+ const errors = b["errors"];
253
+ if (typeof errors !== "object" || errors === null) {
254
+ if (typeof b["message"] === "string") {
255
+ process.stderr.write(`Error: ${b["message"]}
256
+ `);
257
+ return true;
258
+ }
259
+ return false;
260
+ }
261
+ if (typeof b["message"] === "string") {
262
+ process.stderr.write(`Error: ${b["message"]}
263
+ `);
264
+ } else {
265
+ process.stderr.write("Error: validation failed\n");
266
+ }
267
+ for (const [field, msgs] of Object.entries(errors)) {
268
+ const list = Array.isArray(msgs) ? msgs : [msgs];
269
+ for (const m of list) {
270
+ process.stderr.write(` - ${field}: ${String(m)}
271
+ `);
272
+ }
273
+ }
274
+ return true;
275
+ }
276
+
277
+ // src/commands/auth.ts
278
+ function prompt(rl, question) {
279
+ return new Promise((resolve2) => {
280
+ rl.question(question, (answer) => resolve2(answer));
281
+ });
282
+ }
283
+ async function authLogin() {
284
+ const rl = readline.createInterface({
285
+ input: process.stdin,
286
+ output: process.stdout
287
+ });
288
+ try {
289
+ const profileName = (await prompt(rl, "Profile name (default): ")).trim() || "default";
290
+ const baseUrl = (await prompt(rl, "Base URL (https://api-hub.hrplane.com/api/v1): ")).trim() || "https://api-hub.hrplane.com/api/v1";
291
+ const token = (await prompt(rl, "Token: ")).trim();
292
+ rl.close();
293
+ const config = loadConfig() ?? { currentProfile: profileName, profiles: {} };
294
+ config.profiles[profileName] = { baseUrl, token, profileName };
295
+ config.currentProfile = profileName;
296
+ saveConfig(config);
297
+ console.log(`
298
+ Profile "${profileName}" saved and set as current.`);
299
+ try {
300
+ const profile = { baseUrl, token, profileName };
301
+ const me = await apiRequest(profile, "GET", "/auth/me");
302
+ console.log("\nAuthenticated successfully:");
303
+ printJson(me);
304
+ } catch (err) {
305
+ printError(
306
+ "Could not validate token (config saved anyway)",
307
+ err instanceof Error ? err.message : err
308
+ );
309
+ }
310
+ } catch (err) {
311
+ rl.close();
312
+ printError("Auth failed", err);
313
+ process.exit(1);
314
+ }
315
+ }
316
+ async function authStatus(profileOverride) {
317
+ const profile = getActiveProfile(profileOverride);
318
+ if (!profile) {
319
+ printError("No config found. Run `lphub auth login` to configure.");
320
+ process.exit(1);
321
+ }
322
+ console.log(`Active profile: ${profile.profileName}`);
323
+ console.log(`Base URL: ${profile.baseUrl}`);
324
+ try {
325
+ const me = await apiRequest(profile, "GET", "/auth/me");
326
+ console.log("\nLogged in as:");
327
+ printJson(me);
328
+ } catch (err) {
329
+ if (err instanceof ApiError) {
330
+ printError(`Token check failed (HTTP ${err.status})`, err.body);
331
+ process.exit(exitCodeFor(err.status));
332
+ }
333
+ printError("Could not reach the API", err instanceof Error ? err.message : err);
334
+ process.exit(1);
335
+ }
336
+ }
337
+ function authList() {
338
+ const config = loadConfig();
339
+ if (!config || Object.keys(config.profiles).length === 0) {
340
+ printError("No profiles configured. Run `lphub auth login`.");
341
+ process.exit(1);
342
+ }
343
+ for (const name of Object.keys(config.profiles)) {
344
+ const marker = name === config.currentProfile ? "*" : " ";
345
+ const p = config.profiles[name];
346
+ console.log(`${marker} ${name} (${p.baseUrl})`);
347
+ }
348
+ }
349
+ function authSwitch(name) {
350
+ const config = loadConfig();
351
+ if (!config) {
352
+ printError("No config found. Run `lphub auth login` to configure.");
353
+ process.exit(1);
354
+ }
355
+ if (!config.profiles[name]) {
356
+ printError(`Profile "${name}" not found. Run \`lphub auth list\` to see profiles.`);
357
+ process.exit(1);
358
+ }
359
+ config.currentProfile = name;
360
+ saveConfig(config);
361
+ console.log(`Switched to profile "${name}".`);
362
+ }
363
+ function authLogout(name) {
364
+ const config = loadConfig();
365
+ if (!config) {
366
+ printError("No config found.");
367
+ process.exit(1);
368
+ }
369
+ const target = name ?? config.currentProfile;
370
+ if (!config.profiles[target]) {
371
+ printError(`Profile "${target}" not found.`);
372
+ process.exit(1);
373
+ }
374
+ delete config.profiles[target];
375
+ if (config.currentProfile === target) {
376
+ const remaining = Object.keys(config.profiles);
377
+ config.currentProfile = remaining[0] ?? "";
378
+ }
379
+ saveConfig(config);
380
+ console.log(`Removed profile "${target}".`);
381
+ if (config.currentProfile) {
382
+ console.log(`Current profile is now "${config.currentProfile}".`);
383
+ } else {
384
+ console.log("No profiles remain. Run `lphub auth login` to add one.");
385
+ }
386
+ }
387
+
388
+ // src/commands/sync.ts
389
+ async function syncCommand(profileOverride) {
390
+ const profile = getActiveProfile(profileOverride);
391
+ if (!profile) {
392
+ printError("No config found. Run `lphub auth login` to configure.");
393
+ process.exit(1);
394
+ }
395
+ try {
396
+ const manifest = await apiRequest(profile, "GET", "/cli-manifest");
397
+ saveManifest(manifest);
398
+ const count = Array.isArray(manifest.commands) ? manifest.commands.length : 0;
399
+ console.log(`Manifest synced. ${count} top-level command(s) loaded.`);
400
+ } catch (err) {
401
+ if (err instanceof ApiError) {
402
+ printError(`Sync failed (HTTP ${err.status})`, err.body);
403
+ process.exit(exitCodeFor(err.status));
404
+ }
405
+ printError(err instanceof Error ? err.message : "Sync failed");
406
+ process.exit(1);
407
+ }
408
+ }
409
+
410
+ // src/commands/me.ts
411
+ async function meCommand(profileOverride, opts = {}) {
412
+ const profile = getActiveProfile(profileOverride);
413
+ if (!profile) {
414
+ printError("No config found. Run `lphub auth login` to configure.");
415
+ process.exit(1);
416
+ }
417
+ try {
418
+ const result = await apiRequest(profile, "GET", "/auth/me");
419
+ printResult(result, opts);
420
+ } catch (err) {
421
+ if (err instanceof ApiError) {
422
+ printError(`Request failed (HTTP ${err.status})`, err.body);
423
+ process.exit(exitCodeFor(err.status));
424
+ }
425
+ printError(err instanceof Error ? err.message : "Request failed");
426
+ process.exit(1);
427
+ }
428
+ }
429
+
430
+ // src/commands/agent-setup.ts
431
+ import { spawnSync } from "child_process";
432
+ import { mkdtempSync, writeFileSync as writeFileSync2 } from "fs";
433
+ import { tmpdir } from "os";
434
+ import { join as join2 } from "path";
435
+ async function agentSetupCommand(passthrough = [], profileOverride) {
436
+ const profile = getActiveProfile(profileOverride);
437
+ if (!profile) {
438
+ printError("No config found. Run `lphub auth login` to configure.");
439
+ process.exit(1);
440
+ }
441
+ let content;
442
+ try {
443
+ const res2 = await apiRequest(profile, "GET", "/cli-skill");
444
+ content = res2.content ?? "";
445
+ if (!content.trim()) {
446
+ printError("The API returned an empty skill.");
447
+ process.exit(1);
448
+ }
449
+ } catch (err) {
450
+ if (err instanceof ApiError) {
451
+ printError(`Could not fetch the skill (HTTP ${err.status})`, err.body);
452
+ process.exit(exitCodeFor(err.status));
453
+ }
454
+ printError(err instanceof Error ? err.message : "Could not fetch the skill");
455
+ process.exit(1);
456
+ }
457
+ const dir = mkdtempSync(join2(tmpdir(), "lphub-skill-"));
458
+ writeFileSync2(join2(dir, "SKILL.md"), content, "utf-8");
459
+ const args = ["skills", "add", dir, ...passthrough];
460
+ console.log(`Installing the lphub skill via skills.sh:
461
+ npx ${args.join(" ")}
462
+ `);
463
+ const res = spawnSync("npx", args, { stdio: "inherit" });
464
+ if (res.error) {
465
+ printError(
466
+ "Could not run `npx skills add` (is Node/npx available?)",
467
+ res.error.message
468
+ );
469
+ process.exit(1);
470
+ }
471
+ process.exit(res.status ?? 0);
472
+ }
473
+
474
+ // src/dynamic.ts
475
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
476
+ import { join as join4 } from "path";
477
+ import { homedir as homedir2 } from "os";
478
+
479
+ // src/context.ts
480
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
481
+ import { join as join3, dirname as dirname2 } from "path";
482
+ var cachedContext = void 0;
483
+ function loadLocalContext() {
484
+ if (cachedContext !== void 0) return cachedContext;
485
+ let dir = process.cwd();
486
+ while (true) {
487
+ const candidate = join3(dir, ".lphub.json");
488
+ if (existsSync2(candidate)) {
489
+ try {
490
+ cachedContext = JSON.parse(readFileSync2(candidate, "utf-8"));
491
+ return cachedContext;
492
+ } catch {
493
+ break;
494
+ }
495
+ }
496
+ const parent = dirname2(dir);
497
+ if (parent === dir) break;
498
+ dir = parent;
499
+ }
500
+ cachedContext = null;
501
+ return null;
502
+ }
503
+ function getLocalContext() {
504
+ return cachedContext ?? null;
505
+ }
506
+ function getContextPath() {
507
+ return join3(process.cwd(), ".lphub.json");
508
+ }
509
+
510
+ // src/interactive.ts
511
+ import * as readline2 from "readline";
512
+ import { readFileSync as readFileSync3 } from "fs";
513
+ import { resolve } from "path";
514
+ function readBodyFromFile(path) {
515
+ if (path === "-") {
516
+ return readFileSync3(0, "utf-8");
517
+ }
518
+ return readFileSync3(resolve(process.cwd(), path), "utf-8");
519
+ }
520
+ async function confirm(question) {
521
+ if (!process.stdin.isTTY) return true;
522
+ const rl = readline2.createInterface({
523
+ input: process.stdin,
524
+ output: process.stderr
525
+ });
526
+ try {
527
+ const answer = await new Promise(
528
+ (res) => rl.question(`${question} `, (a) => res(a))
529
+ );
530
+ return /^y(es)?$/i.test(answer.trim());
531
+ } finally {
532
+ rl.close();
533
+ }
534
+ }
535
+
536
+ // src/dynamic.ts
537
+ var CONTEXT_FILLABLE = {
538
+ "project-id": "projectId"
539
+ };
540
+ function loadManifestSync() {
541
+ const manifestPath = join4(homedir2(), ".lphub", "manifest.json");
542
+ if (!existsSync3(manifestPath)) return null;
543
+ try {
544
+ return JSON.parse(readFileSync4(manifestPath, "utf-8"));
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+ function optKey(flagName) {
550
+ return flagName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
551
+ }
552
+ function wireKey(name) {
553
+ return name.replace(/-/g, "_");
554
+ }
555
+ function registerDynamicCommands(program2, jsonMode2) {
556
+ const manifest = loadManifestSync();
557
+ if (!manifest) return;
558
+ for (const cmd of manifest.commands) {
559
+ const parentCmd = program2.command(cmd.name).description(cmd.description ?? "");
560
+ for (const sub of cmd.subcommands) {
561
+ let subCmd = parentCmd.command(sub.name).description(sub.description ?? "");
562
+ for (const param of sub.pathParams ?? []) {
563
+ subCmd = subCmd.argument(`<${param.name}>`);
564
+ }
565
+ const method = sub.method.toUpperCase();
566
+ for (const opt of sub.options ?? []) {
567
+ let flag;
568
+ if (opt.type === "boolean") {
569
+ flag = `--${opt.name}`;
570
+ } else if (opt.type === "array") {
571
+ flag = `--${opt.name} <values...>`;
572
+ } else {
573
+ flag = `--${opt.name} <value>`;
574
+ }
575
+ const description = opt.enum && opt.enum.length > 0 ? `Choices: ${opt.enum.join(", ")}` : "";
576
+ if (opt.required && !(opt.name in CONTEXT_FILLABLE)) {
577
+ subCmd = subCmd.requiredOption(flag, description);
578
+ } else {
579
+ subCmd = subCmd.option(flag, description);
580
+ }
581
+ if (opt.type === "string") {
582
+ subCmd = subCmd.option(
583
+ `--${opt.name}-file <path>`,
584
+ `Read --${opt.name} from a file (use - for STDIN)`
585
+ );
586
+ }
587
+ }
588
+ subCmd = subCmd.option("--json", "Output raw JSON instead of a table").option(
589
+ "--fields <list>",
590
+ "Comma-separated dot-paths to project (e.g. id,title,project.name)"
591
+ );
592
+ if (method === "DELETE") {
593
+ subCmd = subCmd.option("-y, --yes", "Skip the confirmation prompt");
594
+ }
595
+ const capturedSub = sub;
596
+ const capturedResource = cmd.name;
597
+ subCmd.action(async (...args) => {
598
+ const profileOverride = program2.opts()["profile"];
599
+ const profile = getActiveProfile(profileOverride);
600
+ if (!profile) {
601
+ printError("No config found. Run `lphub auth login` to configure.");
602
+ process.exit(1);
603
+ }
604
+ const pathParams = capturedSub.pathParams ?? [];
605
+ const positionalArgs = args.slice(0, pathParams.length);
606
+ const opts = args[pathParams.length] ?? {};
607
+ let resolvedPath = capturedSub.path;
608
+ for (let i = 0; i < pathParams.length; i++) {
609
+ resolvedPath = resolvedPath.replace(
610
+ `{${pathParams[i].name}}`,
611
+ positionalArgs[i] ?? ""
612
+ );
613
+ }
614
+ const useJson = !!opts["json"] || jsonMode2();
615
+ const fieldsRaw = opts["fields"];
616
+ const fields = fieldsRaw ? fieldsRaw.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
617
+ const skipConfirm = !!opts["yes"];
618
+ const manifestOpts = capturedSub.options ?? [];
619
+ const pathParamNames = new Set(pathParams.map((p) => p.name));
620
+ const payload = {};
621
+ for (const mo of manifestOpts) {
622
+ if (pathParamNames.has(mo.name)) continue;
623
+ const key = optKey(mo.name);
624
+ if (mo.type === "string") {
625
+ const fileKey = optKey(`${mo.name}-file`);
626
+ const direct = opts[key];
627
+ const fromFile = opts[fileKey];
628
+ if (direct !== void 0 && fromFile !== void 0) {
629
+ printError(`--${mo.name} and --${mo.name}-file are mutually exclusive.`);
630
+ process.exit(1);
631
+ }
632
+ if (fromFile !== void 0) {
633
+ try {
634
+ payload[mo.name] = readBodyFromFile(fromFile);
635
+ } catch (err) {
636
+ printError(
637
+ `Could not read --${mo.name}-file`,
638
+ err instanceof Error ? err.message : err
639
+ );
640
+ process.exit(1);
641
+ }
642
+ } else if (direct !== void 0) {
643
+ payload[mo.name] = direct;
644
+ }
645
+ } else if (mo.type === "boolean") {
646
+ if (opts[key] !== void 0) payload[mo.name] = !!opts[key];
647
+ } else {
648
+ const v = opts[key];
649
+ if (v !== void 0) payload[mo.name] = v;
650
+ }
651
+ }
652
+ const ctx = getLocalContext();
653
+ for (const [optName, ctxField] of Object.entries(CONTEXT_FILLABLE)) {
654
+ const mo = manifestOpts.find((o) => o.name === optName);
655
+ if (!mo) continue;
656
+ const key = optKey(optName);
657
+ if (payload[optName] === void 0 && opts[key] === void 0) {
658
+ const ctxValue = ctx?.[ctxField];
659
+ if (ctxValue !== void 0) {
660
+ payload[optName] = String(ctxValue);
661
+ } else if (mo.required) {
662
+ printError(
663
+ `--${optName} is required (or run \`lphub init\` to set a default project)`
664
+ );
665
+ process.exit(1);
666
+ }
667
+ }
668
+ }
669
+ if (method === "DELETE" && !skipConfirm) {
670
+ const id = positionalArgs[positionalArgs.length - 1] ?? "";
671
+ const ok = await confirm(`Delete ${capturedResource} ${id}? (y/N)`);
672
+ if (!ok) {
673
+ process.stderr.write("Aborted.\n");
674
+ process.exit(1);
675
+ }
676
+ }
677
+ try {
678
+ let result;
679
+ if (method === "GET") {
680
+ const query = {};
681
+ for (const [k, v] of Object.entries(payload)) {
682
+ if (v === void 0 || v === null) continue;
683
+ query[wireKey(k)] = Array.isArray(v) ? v.join(",") : String(v);
684
+ }
685
+ result = await apiRequest(
686
+ profile,
687
+ method,
688
+ resolvedPath,
689
+ void 0,
690
+ Object.keys(query).length > 0 ? query : void 0
691
+ );
692
+ } else if (method === "DELETE") {
693
+ result = await apiRequest(profile, method, resolvedPath);
694
+ } else {
695
+ const body = Object.fromEntries(
696
+ Object.entries(payload).map(([k, v]) => [wireKey(k), v])
697
+ );
698
+ result = await apiRequest(
699
+ profile,
700
+ method,
701
+ resolvedPath,
702
+ Object.keys(body).length > 0 ? body : void 0
703
+ );
704
+ }
705
+ printResult(result, { json: useJson, fields });
706
+ } catch (err) {
707
+ if (err instanceof ApiError) {
708
+ if (err.status === 422 && printValidationError(err.body)) {
709
+ process.exit(1);
710
+ }
711
+ printError(`${err.status} ${method} ${resolvedPath}`, err.body);
712
+ process.exit(exitCodeFor(err.status));
713
+ }
714
+ printError(err instanceof Error ? err.message : "Request failed");
715
+ process.exit(1);
716
+ }
717
+ });
718
+ }
719
+ }
720
+ }
721
+
722
+ // src/commands/init.ts
723
+ import * as readline3 from "readline";
724
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, appendFileSync } from "fs";
725
+ import { join as join5 } from "path";
726
+ function prompt2(rl, question) {
727
+ return new Promise((resolve2) => {
728
+ rl.question(question, (answer) => resolve2(answer));
729
+ });
730
+ }
731
+ async function initCommand() {
732
+ const config = loadConfig();
733
+ if (!config || Object.keys(config.profiles).length === 0) {
734
+ printError("No profiles configured. Run `lphub auth login` first.");
735
+ process.exit(1);
736
+ }
737
+ const rl = readline3.createInterface({
738
+ input: process.stdin,
739
+ output: process.stdout
740
+ });
741
+ try {
742
+ const profileNames = Object.keys(config.profiles);
743
+ console.log("\nAvailable profiles:");
744
+ for (const name of profileNames) {
745
+ const marker = name === config.currentProfile ? "* " : " ";
746
+ console.log(`${marker}${name}`);
747
+ }
748
+ const profileAnswer = (await prompt2(rl, `
749
+ Profile [${config.currentProfile}]: `)).trim();
750
+ const chosenProfile = profileAnswer || config.currentProfile;
751
+ if (!config.profiles[chosenProfile]) {
752
+ printError(`Profile "${chosenProfile}" not found.`);
753
+ rl.close();
754
+ process.exit(1);
755
+ }
756
+ const profile = getActiveProfile(chosenProfile);
757
+ if (!profile) {
758
+ printError("Could not load profile. Run `lphub auth login`.");
759
+ rl.close();
760
+ process.exit(1);
761
+ }
762
+ let projects;
763
+ try {
764
+ const res = await apiRequest(profile, "GET", "/projects");
765
+ projects = Array.isArray(res) ? res : res.data ?? [];
766
+ } catch (err) {
767
+ rl.close();
768
+ if (err instanceof ApiError) {
769
+ printError(`Failed to fetch projects (HTTP ${err.status})`, err.body);
770
+ process.exit(1);
771
+ }
772
+ if (err instanceof NetworkError) {
773
+ printError(err.message);
774
+ process.exit(1);
775
+ }
776
+ printError(err instanceof Error ? err.message : "Request failed");
777
+ process.exit(1);
778
+ }
779
+ if (projects.length === 0) {
780
+ printError("No projects found for this profile.");
781
+ rl.close();
782
+ process.exit(1);
783
+ }
784
+ console.log("\nAvailable projects:");
785
+ for (let i = 0; i < projects.length; i++) {
786
+ console.log(` ${i + 1}. ${projects[i].id} ${projects[i].name}`);
787
+ }
788
+ const projectAnswer = (await prompt2(rl, "\nEnter project ID (or list number): ")).trim();
789
+ let chosenProjectId;
790
+ const asNumber = Number(projectAnswer);
791
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= projects.length) {
792
+ const byId = projects.find((p) => p.id === asNumber);
793
+ if (byId) {
794
+ chosenProjectId = byId.id;
795
+ } else {
796
+ chosenProjectId = projects[asNumber - 1].id;
797
+ }
798
+ } else if (!Number.isNaN(asNumber) && Number.isInteger(asNumber)) {
799
+ chosenProjectId = asNumber;
800
+ }
801
+ if (chosenProjectId === void 0 || !projects.find((p) => p.id === chosenProjectId)) {
802
+ printError(`"${projectAnswer}" is not a valid project ID or list number.`);
803
+ rl.close();
804
+ process.exit(1);
805
+ }
806
+ const chosenProject = projects.find((p) => p.id === chosenProjectId);
807
+ const contextPath = getContextPath();
808
+ const contextData = { profile: chosenProfile, projectId: chosenProjectId };
809
+ writeFileSync3(contextPath, JSON.stringify(contextData, null, 2) + "\n", "utf-8");
810
+ const gitignorePath = join5(process.cwd(), ".gitignore");
811
+ if (existsSync4(gitignorePath)) {
812
+ const gitignoreContent = readFileSync5(gitignorePath, "utf-8");
813
+ if (!gitignoreContent.split("\n").some((l) => l.trim() === ".lphub.json")) {
814
+ const addToGitignore = (await prompt2(rl, "Add .lphub.json to .gitignore? (Y/n): ")).trim().toLowerCase();
815
+ if (addToGitignore !== "n" && addToGitignore !== "no") {
816
+ const separator = gitignoreContent.endsWith("\n") ? "" : "\n";
817
+ appendFileSync(gitignorePath, `${separator}.lphub.json
818
+ `, "utf-8");
819
+ console.log("Added .lphub.json to .gitignore.");
820
+ }
821
+ }
822
+ } else {
823
+ console.log(
824
+ "\nNote: .lphub.json is personal and should not be committed. Add it to your .gitignore."
825
+ );
826
+ }
827
+ rl.close();
828
+ console.log(`
829
+ Initialized: profile "${chosenProfile}", project "${chosenProject.name}" (id: ${chosenProjectId})`);
830
+ console.log(`Commands in this directory now auto-scope to project ${chosenProjectId}.`);
831
+ } catch (err) {
832
+ rl.close();
833
+ printError("Init failed", err instanceof Error ? err.message : err);
834
+ process.exit(1);
835
+ }
836
+ }
837
+
838
+ // src/commands/docs.ts
839
+ async function docsCommand(profileOverride, topic) {
840
+ const profile = getActiveProfile(profileOverride);
841
+ if (!profile) {
842
+ printError("No config found. Run `lphub auth login` to configure.");
843
+ process.exit(1);
844
+ }
845
+ try {
846
+ if (!topic) {
847
+ const res = await apiRequest(profile, "GET", "/cli-docs");
848
+ const topics = res.topics ?? [];
849
+ if (topics.length === 0) {
850
+ console.log("No docs topics available.");
851
+ return;
852
+ }
853
+ const width = Math.max(...topics.map((t) => t.name.length));
854
+ console.log("Available docs topics (use `lphub docs <topic>`):\n");
855
+ for (const t of topics) {
856
+ console.log(` ${t.name.padEnd(width)} ${t.title}`);
857
+ }
858
+ } else {
859
+ const res = await apiRequest(
860
+ profile,
861
+ "GET",
862
+ `/cli-docs/${encodeURIComponent(topic)}`
863
+ );
864
+ process.stdout.write((res.content ?? "") + "\n");
865
+ }
866
+ } catch (err) {
867
+ if (err instanceof ApiError) {
868
+ if (err.status === 404) {
869
+ printError(
870
+ `Unknown docs topic "${topic}". Run \`lphub docs\` to list topics.`
871
+ );
872
+ process.exit(1);
873
+ }
874
+ printError(`Docs failed (HTTP ${err.status})`, err.body);
875
+ process.exit(exitCodeFor(err.status));
876
+ }
877
+ printError(err instanceof Error ? err.message : "Docs failed");
878
+ process.exit(1);
879
+ }
880
+ }
881
+
882
+ // src/index.ts
883
+ var program = new Command();
884
+ program.name("lphub").description("LogicPanel Hub CLI").option("--profile <name>", "Override active profile").option("--base-url <url>", "Override the API base URL (or set LPHUB_BASE_URL)").option("--json", "Compact JSON output");
885
+ program.hook("preAction", () => {
886
+ setBaseUrlOverride(program.opts()["baseUrl"]);
887
+ const ctx = loadLocalContext();
888
+ setContextProfile(ctx?.profile);
889
+ });
890
+ var globalOpts = () => program.opts();
891
+ var jsonMode = () => !!globalOpts()["json"];
892
+ var parseFields = (raw) => raw ? raw.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
893
+ registerDynamicCommands(program, jsonMode);
894
+ var authCmd = program.command("auth").description("Authenticate and manage profiles").action(async () => {
895
+ await authLogin();
896
+ });
897
+ authCmd.command("login").description("Interactively configure a profile (base URL + PAT token)").action(async () => {
898
+ await authLogin();
899
+ });
900
+ authCmd.command("status").description("Show the active profile and whoami").action(async () => {
901
+ await authStatus(globalOpts()["profile"]);
902
+ });
903
+ authCmd.command("list").description("List configured profiles").action(() => {
904
+ authList();
905
+ });
906
+ authCmd.command("switch <profile>").description("Set the current profile").action((name) => {
907
+ authSwitch(name);
908
+ });
909
+ authCmd.command("logout [profile]").description("Remove a profile (default: current)").action((name) => {
910
+ authLogout(name);
911
+ });
912
+ program.command("sync").description("Refresh the local command manifest from the API").action(async () => {
913
+ await syncCommand(globalOpts()["profile"]);
914
+ });
915
+ program.command("me").description("Print the authenticated user").option("--json", "Output raw JSON instead of a table").option("--fields <list>", "Comma-separated dot-paths to project").action(async (opts) => {
916
+ await meCommand(globalOpts()["profile"], {
917
+ json: !!opts.json || jsonMode(),
918
+ fields: parseFields(opts.fields)
919
+ });
920
+ });
921
+ var agentCmd = program.command("agent").description("Agent integration utilities");
922
+ agentCmd.command("setup").description("Fetch the agent skill from the API and install it via skills.sh").allowUnknownOption(true).helpOption(false).action(async () => {
923
+ const i = process.argv.lastIndexOf("setup");
924
+ const passthrough = i >= 0 ? process.argv.slice(i + 1) : [];
925
+ await agentSetupCommand(
926
+ passthrough,
927
+ globalOpts()["profile"]
928
+ );
929
+ });
930
+ program.command("init").description("Pin a profile and project to this directory (.lphub.json)").action(async () => {
931
+ await initCommand();
932
+ });
933
+ program.command("docs [topic]").description("Show LogicPanel Hub guides (list topics, or print one in full)").action(async (topic) => {
934
+ await docsCommand(globalOpts()["profile"], topic);
935
+ });
936
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@logicpanel/lphub",
3
+ "version": "0.1.0",
4
+ "description": "Thin CLI client for the LogicPanel Hub API — manifest-driven, gh-style.",
5
+ "keywords": [
6
+ "logicpanel",
7
+ "lphub",
8
+ "cli",
9
+ "tickets",
10
+ "issues",
11
+ "api-client"
12
+ ],
13
+ "license": "UNLICENSED",
14
+ "type": "module",
15
+ "bin": {
16
+ "lphub": "./dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://gitlab.com/logicpanel/logicpanel-hub-client.git",
28
+ "directory": "packages/lphub"
29
+ },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "dev": "tsup --watch",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "prepack": "tsup"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^12.1.0"
38
+ },
39
+ "devDependencies": {
40
+ "tsup": "^8.3.5",
41
+ "typescript": "^5.6.3",
42
+ "@types/node": "^22.9.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }