pikiloom 0.4.21 → 0.4.23
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/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-hzdSX7gP.js} +1 -1
- package/dashboard/dist/assets/ConnectionModal-Dj2eoHWz.js +1 -0
- package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-C4DPPZ1V.js} +1 -1
- package/dashboard/dist/assets/ExtensionsTab-BdGHiEnP.js +1 -0
- package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-C4RlXeCP.js} +1 -1
- package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-D22Z00Ux.js} +1 -1
- package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-CHXLG4V2.js} +1 -1
- package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D4JIewGZ.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-Ba93D9jh.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-M8r-o2KI.js} +1 -1
- package/dashboard/dist/assets/index-C02W4e24.js +3 -0
- package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
- package/dashboard/dist/assets/{index-7pyJSn4B.js → index-p2XCgnzw.js} +17 -17
- package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-DW8iXiGd.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/index.js +1 -1
- package/dist/agent/skill-installer.js +95 -0
- package/dist/catalog/skill-repos.js +12 -0
- package/dist/cli/main.js +31 -1
- package/dist/core/secrets/ref.js +8 -0
- package/dist/core/secrets/store.js +31 -11
- package/dist/dashboard/routes/extensions.js +137 -24
- package/dist/dashboard/server.js +27 -64
- package/dist/pikichannel/adapter-pikiloom.js +220 -0
- package/dist/pikichannel/code.js +35 -0
- package/dist/pikichannel/codec.js +50 -0
- package/dist/pikichannel/host.js +252 -0
- package/dist/pikichannel/protocol.js +105 -0
- package/dist/pikichannel/rendezvous-broker.js +114 -0
- package/dist/pikichannel/rendezvous-host.js +138 -0
- package/dist/pikichannel/server.js +284 -0
- package/dist/pikichannel/transport.js +49 -0
- package/dist/pikichannel/transports/webrtc-host.js +61 -0
- package/dist/pikichannel/transports/webrtc-shared.js +132 -0
- package/dist/pikichannel/transports/websocket-host.js +78 -0
- package/dist/pikichannel/web/demo.html +246 -0
- package/dist/pikichannel/web/sdk.js +361 -0
- package/package.json +4 -2
- package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
- package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
- package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as s}from"./react-vendor-C7Sl8SE7.js";import{
|
|
1
|
+
import{j as s}from"./react-vendor-C7Sl8SE7.js";import{N as t,m as i,S as a}from"./index-C02W4e24.js";function o({primary:e,secondary:l,tertiary:n}){return s.jsxs("div",{className:"flex flex-col gap-2 pt-1 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between",children:[s.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e&&s.jsxs(i,{tone:"primary",onClick:e.onClick,disabled:e.disabled,children:[e.loading&&s.jsx(a,{}),e.label]}),l&&s.jsx(i,{tone:"secondary",onClick:l.onClick,disabled:l.disabled,children:l.label})]}),n&&s.jsx("div",{className:"text-xs leading-relaxed text-fg-4",children:n})]})}function c({children:e,className:l}){return s.jsx(t,{padding:"md",elevation:"flat",className:l,children:e})}export{o as A,c as S};
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
<link rel="icon" type="image/png" href="/logo.png">
|
|
7
7
|
<title>Pikiloom</title>
|
|
8
8
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-C02W4e24.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/react-vendor-C7Sl8SE7.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/router-DHISdpPk.js">
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CyCspgtc.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/dist/agent/index.js
CHANGED
|
@@ -42,7 +42,7 @@ export { getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalProm
|
|
|
42
42
|
export { listAllMcpExtensions, addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, loadGlobalMcpExtensions, loadWorkspaceMcpExtensions, getCatalogItems, getCatalogItem, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, } from './mcp/extensions.js';
|
|
43
43
|
export { getRecommendedMcpServers, getRecommendedMcpServer, getRecommendedSkillRepos, searchMcpServers, searchSkills as searchSkillRepos, } from './mcp/registry.js';
|
|
44
44
|
export { getMcpToken, saveMcpToken, deleteMcpToken, hasValidMcpToken, startAuthorization, completeAuthorization, refreshMcpToken, injectOAuthHeaders, } from './mcp/oauth.js';
|
|
45
|
-
export { installSkill, removeSkill, checkSkillUpdates, getGlobalSkillsDir, } from './skill-installer.js';
|
|
45
|
+
export { installSkill, removeSkill, checkSkillUpdates, getGlobalSkillsDir, recordSkillInstall, getSkillLedgerEntry, forgetSkillInstall, normalizeSkillSourceKey, } from './skill-installer.js';
|
|
46
46
|
// ── Re-export: CLI extensions ───────────────────────────────────────────────
|
|
47
47
|
export { getRecommendedClis, getRecommendedCli, detectCli, getCachedCliStatus, invalidateCliStatus, currentPlatform, getCliCatalog, refreshCliStatus, startCliAuthSession, getAuthSession, cancelAuthSession, applyCliToken, logoutCli, startCliInstallSession, } from './cli/index.js';
|
|
48
48
|
// ── Re-export: driver-specific functions ────────────────────────────────────
|
|
@@ -20,6 +20,93 @@ const GLOBAL_SKILLS_DIR = path.join(os.homedir(), STATE_DIR_NAME, 'skills');
|
|
|
20
20
|
const INSTALL_TIMEOUT_MS = 60_000;
|
|
21
21
|
const REMOVE_TIMEOUT_MS = 10_000;
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
|
+
// Install ledger — provenance for update detection
|
|
24
|
+
//
|
|
25
|
+
// `npx skills add` drops skill directories but records no source or version, so
|
|
26
|
+
// there's nothing to diff a local install against a moving remote. We keep a
|
|
27
|
+
// sidecar ledger next to the skills (one per scope: global beside
|
|
28
|
+
// ~/.pikiloom/skills, project beside <workdir>/.pikiloom/skills) mapping each
|
|
29
|
+
// installed collection (its GitHub source) to the default-branch commit SHA it
|
|
30
|
+
// was pulled from. The dashboard reads the live remote HEAD, diffs it against
|
|
31
|
+
// the ledger to surface "update available", then re-runs install to advance it.
|
|
32
|
+
//
|
|
33
|
+
// The file is a hidden sibling of the skill dirs; skill discovery and removal
|
|
34
|
+
// only ever touch named sub-directories, so it's invisible to both.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const SKILL_LEDGER_FILE = '.pikiloom-skills-ledger.json';
|
|
37
|
+
function ledgerPath(opts) {
|
|
38
|
+
if (opts.global)
|
|
39
|
+
return path.join(GLOBAL_SKILLS_DIR, SKILL_LEDGER_FILE);
|
|
40
|
+
if (opts.workdir)
|
|
41
|
+
return path.join(opts.workdir, STATE_DIR_NAME, 'skills', SKILL_LEDGER_FILE);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** Normalize a source to a stable key: lowercase `owner/repo`, URL/.git stripped. */
|
|
45
|
+
export function normalizeSkillSourceKey(source) {
|
|
46
|
+
return String(source || '')
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^https?:\/\/(www\.)?github\.com\//i, '')
|
|
49
|
+
.replace(/\.git$/i, '')
|
|
50
|
+
.replace(/\/+$/, '')
|
|
51
|
+
.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
function readLedger(opts) {
|
|
54
|
+
const p = ledgerPath(opts);
|
|
55
|
+
if (!p)
|
|
56
|
+
return { version: 1, entries: {} };
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
59
|
+
if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
|
|
60
|
+
return { version: 1, entries: parsed.entries };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { /* missing or corrupt — treat as empty */ }
|
|
64
|
+
return { version: 1, entries: {} };
|
|
65
|
+
}
|
|
66
|
+
function writeLedger(ledger, opts) {
|
|
67
|
+
const p = ledgerPath(opts);
|
|
68
|
+
if (!p)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
72
|
+
fs.writeFileSync(p, JSON.stringify(ledger, null, 2));
|
|
73
|
+
}
|
|
74
|
+
catch { /* best effort — provenance is non-critical */ }
|
|
75
|
+
}
|
|
76
|
+
/** Look up the recorded provenance for a source in the given scope. */
|
|
77
|
+
export function getSkillLedgerEntry(source, opts) {
|
|
78
|
+
const key = normalizeSkillSourceKey(source);
|
|
79
|
+
if (!key)
|
|
80
|
+
return null;
|
|
81
|
+
return readLedger(opts).entries[key] || null;
|
|
82
|
+
}
|
|
83
|
+
/** Record (or refresh) the provenance for a source after a successful install/update. */
|
|
84
|
+
export function recordSkillInstall(source, opts) {
|
|
85
|
+
const key = normalizeSkillSourceKey(source);
|
|
86
|
+
if (!key)
|
|
87
|
+
return;
|
|
88
|
+
const ledger = readLedger(opts);
|
|
89
|
+
const prev = ledger.entries[key];
|
|
90
|
+
ledger.entries[key] = {
|
|
91
|
+
source: source.trim(),
|
|
92
|
+
sha: opts.sha ?? prev?.sha ?? null,
|
|
93
|
+
installedAt: Date.now(),
|
|
94
|
+
names: opts.names && opts.names.length ? opts.names : prev?.names,
|
|
95
|
+
};
|
|
96
|
+
writeLedger(ledger, opts);
|
|
97
|
+
}
|
|
98
|
+
/** Drop a source's provenance — call when its skills are removed. */
|
|
99
|
+
export function forgetSkillInstall(source, opts) {
|
|
100
|
+
const key = normalizeSkillSourceKey(source);
|
|
101
|
+
if (!key)
|
|
102
|
+
return;
|
|
103
|
+
const ledger = readLedger(opts);
|
|
104
|
+
if (ledger.entries[key]) {
|
|
105
|
+
delete ledger.entries[key];
|
|
106
|
+
writeLedger(ledger, opts);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
23
110
|
// Helpers
|
|
24
111
|
// ---------------------------------------------------------------------------
|
|
25
112
|
/**
|
|
@@ -98,6 +185,14 @@ export async function installSkill(source, opts = {}) {
|
|
|
98
185
|
const errorMsg = result.stderr.trim().split('\n').pop()?.trim() || 'installation failed';
|
|
99
186
|
return { ok: false, error: errorMsg, output: result.stdout + result.stderr };
|
|
100
187
|
}
|
|
188
|
+
// Record provenance so the dashboard can later detect remote updates. Scoped
|
|
189
|
+
// to where the skill landed; best-effort, never fails the install.
|
|
190
|
+
recordSkillInstall(source, {
|
|
191
|
+
global: isGlobal,
|
|
192
|
+
workdir,
|
|
193
|
+
sha: opts.sourceSha ?? null,
|
|
194
|
+
names: opts.sourceNames ?? (skill ? [skill] : undefined),
|
|
195
|
+
});
|
|
101
196
|
return { ok: true, output: result.stdout };
|
|
102
197
|
}
|
|
103
198
|
// ---------------------------------------------------------------------------
|
|
@@ -28,6 +28,18 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export const SKILL_REPOS = [
|
|
30
30
|
// ── Official / first-party ────────────────────────────────────────────────
|
|
31
|
+
{
|
|
32
|
+
id: 'pikiloom-skills',
|
|
33
|
+
name: 'Pikiloom Skills',
|
|
34
|
+
description: 'First-party Pikiloom skills — image generation, recorded demo videos, and the embeddable tech-intel signal pipeline',
|
|
35
|
+
descriptionZh: 'Pikiloom 官方技能 — 生图、产品导览视频、可嵌入的 tech-intel 信号流水线',
|
|
36
|
+
source: 'xiaotonng/pikiloom-skills',
|
|
37
|
+
category: 'productivity',
|
|
38
|
+
recommendedScope: 'both',
|
|
39
|
+
homepage: 'https://github.com/xiaotonng/pikiloom-skills',
|
|
40
|
+
// First-party pack: keep it at the top even before it accrues stars.
|
|
41
|
+
pinned: true,
|
|
42
|
+
},
|
|
31
43
|
{
|
|
32
44
|
id: 'anthropics-skills',
|
|
33
45
|
name: 'Anthropic Skills',
|
package/dist/cli/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import { hasConfiguredChannelToken, resolveConfiguredChannels } from './channels
|
|
|
20
20
|
import { ChannelSupervisor } from './channel-supervisor.js';
|
|
21
21
|
import { listAgents } from '../agent/index.js';
|
|
22
22
|
import { startDashboard } from '../dashboard/server.js';
|
|
23
|
+
import { buildServerCode } from '../pikichannel/code.js';
|
|
23
24
|
import { buildSetupGuide, collectSetupState, hasReadyAgent, isSetupReady } from './onboarding.js';
|
|
24
25
|
import { buildRestartCommand, clearDaemonPidFile, clearRestartStateFile, consumeRestartStateFile, createRestartStateFilePath, isProcessAlive, PROCESS_RESTART_EXIT_CODE, readDaemonPidFile, requestProcessRestart, writeDaemonPidFile, } from '../core/process-control.js';
|
|
25
26
|
import { runSetupWizard } from './setup-wizard.js';
|
|
@@ -185,6 +186,9 @@ function parseArgs(argv) {
|
|
|
185
186
|
case '--no-dashboard':
|
|
186
187
|
args.noDashboard = true;
|
|
187
188
|
break;
|
|
189
|
+
case '--server':
|
|
190
|
+
args.server = true;
|
|
191
|
+
break;
|
|
188
192
|
case '--dashboard-port':
|
|
189
193
|
args.dashboardPort = parseInt(it.next().value ?? '', 10);
|
|
190
194
|
break;
|
|
@@ -254,6 +258,7 @@ Options:
|
|
|
254
258
|
--timeout <seconds> Max seconds per agent request [default: ${DEFAULT_RUN_TIMEOUT_S}]
|
|
255
259
|
--doctor Run setup checks and exit
|
|
256
260
|
--setup Run the interactive setup wizard
|
|
261
|
+
--server Headless server: keep the host running, don't open a browser, print a connection code
|
|
257
262
|
--no-daemon Disable watchdog (auto-restart on crash is ON by default)
|
|
258
263
|
--no-dashboard Skip the web dashboard
|
|
259
264
|
--dashboard-port <port> Dashboard port [default: 3939]
|
|
@@ -521,13 +526,16 @@ async function runSetupPhase(args, userConfig, configOverrides, channels, channe
|
|
|
521
526
|
// Suppress the browser pop on auto-start when there's no user-facing
|
|
522
527
|
// terminal: launchd-spawned bots, Docker/headless server runs, or when
|
|
523
528
|
// the user explicitly set PIKILOOM_OPEN_BROWSER=0.
|
|
524
|
-
const openBrowser = !
|
|
529
|
+
const openBrowser = !args.server
|
|
530
|
+
&& !process.env[FROM_LAUNCHD_ENV]
|
|
525
531
|
&& !envBool('PIKILOOM_DOCKER', false)
|
|
526
532
|
&& envBool('PIKILOOM_OPEN_BROWSER', true);
|
|
527
533
|
dashboard = await startDashboard({
|
|
528
534
|
port: args.dashboardPort || 3939,
|
|
529
535
|
open: openBrowser,
|
|
530
536
|
});
|
|
537
|
+
if (args.server)
|
|
538
|
+
printServerConnectionCode(dashboard);
|
|
531
539
|
if (needsSetup) {
|
|
532
540
|
const ctx = { userConfig, configOverrides, args };
|
|
533
541
|
const resolved = await awaitDashboardConfig(dashboard, ctx);
|
|
@@ -560,6 +568,28 @@ async function runSetupPhase(args, userConfig, configOverrides, channels, channe
|
|
|
560
568
|
}
|
|
561
569
|
return { dashboard, userConfig, channels, channel };
|
|
562
570
|
}
|
|
571
|
+
/** Print the shareable connection code for `--server` (headless) mode. */
|
|
572
|
+
function printServerConnectionCode(dashboard) {
|
|
573
|
+
const c = loadUserConfig();
|
|
574
|
+
const sc = buildServerCode({
|
|
575
|
+
token: process.env.PIKICHANNEL_TOKEN || c.pikichannelToken,
|
|
576
|
+
nodeId: c.pikichannelNodeId,
|
|
577
|
+
publicHost: process.env.PIKICHANNEL_PUBLIC_HOST || c.pikichannelPublicHost,
|
|
578
|
+
rendezvous: process.env.PIKICHANNEL_RENDEZVOUS || c.pikichannelRendezvous,
|
|
579
|
+
});
|
|
580
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
581
|
+
process.stdout.write(`\n[pikiloom ${ts}] server mode — host running, browser not opened\n`);
|
|
582
|
+
process.stdout.write(` local console: ${dashboard.url}\n`);
|
|
583
|
+
if (sc.mode === 'none') {
|
|
584
|
+
process.stdout.write(' to let others connect: set a public address (env PIKICHANNEL_PUBLIC_HOST,\n');
|
|
585
|
+
process.stdout.write(' or the dashboard 连接 → 分享 panel), or enable internet access, then restart.\n\n');
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
process.stdout.write(` connection code (${sc.mode === 'direct' ? 'direct → ' : 'NAT via '}${sc.detail}):\n`);
|
|
589
|
+
process.stdout.write(` ${sc.code}\n`);
|
|
590
|
+
process.stdout.write(' paste it into a client → 连接 → 互联网/局域网.\n\n');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
563
593
|
/* ── Phase: post-setup validation ─────────────────────────────────── */
|
|
564
594
|
/**
|
|
565
595
|
* Re-resolve channels after the setup phase and validate we have a runnable
|
package/dist/core/secrets/ref.js
CHANGED
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** Stable service name used when writing to the OS keychain. */
|
|
12
12
|
export const KEYCHAIN_SERVICE = 'pikiloom';
|
|
13
|
+
/**
|
|
14
|
+
* Legacy keychain service names this build still reads from. The project was
|
|
15
|
+
* renamed (pikiclaw → pikiloom); credentials written under the old service are
|
|
16
|
+
* orphaned because lookups now use `KEYCHAIN_SERVICE`. `readKeychain` falls
|
|
17
|
+
* back to these on a miss and lazily re-writes the secret under the current
|
|
18
|
+
* service, so each orphaned item self-heals on first use.
|
|
19
|
+
*/
|
|
20
|
+
export const LEGACY_KEYCHAIN_SERVICES = ['pikiclaw'];
|
|
13
21
|
export function isCredentialRef(value) {
|
|
14
22
|
if (!value || typeof value !== 'object')
|
|
15
23
|
return false;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OS keychain access — lazy import of @napi-rs/keyring (optional dep).
|
|
3
3
|
* If keychain is unavailable, callers fall back to inline-seal.
|
|
4
4
|
*/
|
|
5
|
-
import { KEYCHAIN_SERVICE } from './ref.js';
|
|
5
|
+
import { KEYCHAIN_SERVICE, LEGACY_KEYCHAIN_SERVICES } from './ref.js';
|
|
6
6
|
let keyringModule; // undefined = not tried, null = failed
|
|
7
7
|
async function loadKeyring() {
|
|
8
8
|
if (keyringModule !== undefined)
|
|
@@ -30,17 +30,37 @@ export async function readKeychain(account) {
|
|
|
30
30
|
const mod = await loadKeyring();
|
|
31
31
|
if (!mod)
|
|
32
32
|
throw new Error('OS keychain unavailable (install @napi-rs/keyring)');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
const readUnder = (service) => {
|
|
34
|
+
try {
|
|
35
|
+
const entry = new mod.Entry(service, account);
|
|
36
|
+
const value = entry.getPassword();
|
|
37
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// keyring-rs returns NoEntry as an error — treat as missing
|
|
41
|
+
if (/NoEntry|no.such|not.found/i.test(e?.message || ''))
|
|
42
|
+
return null;
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const current = readUnder(KEYCHAIN_SERVICE);
|
|
47
|
+
if (current != null)
|
|
48
|
+
return current;
|
|
49
|
+
// Pre-rename fallback: recover orphaned items and migrate them forward so the
|
|
50
|
+
// legacy lookup only ever happens once per account.
|
|
51
|
+
for (const legacy of LEGACY_KEYCHAIN_SERVICES) {
|
|
52
|
+
const value = readUnder(legacy);
|
|
53
|
+
if (value == null)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
new mod.Entry(KEYCHAIN_SERVICE, account).setPassword(value);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// best-effort migration — return the recovered secret regardless
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
43
62
|
}
|
|
63
|
+
return null;
|
|
44
64
|
}
|
|
45
65
|
export async function writeKeychain(account, value) {
|
|
46
66
|
const mod = await loadKeyring();
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { Hono } from 'hono';
|
|
15
15
|
import { execFile } from 'node:child_process';
|
|
16
|
-
import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
|
|
16
|
+
import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, recordSkillInstall, getSkillLedgerEntry, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
|
|
17
17
|
import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
|
|
18
18
|
import { ensurePeekabooWarm } from '../../agent/mcp/bridge.js';
|
|
19
19
|
import { runtime } from '../runtime.js';
|
|
@@ -582,6 +582,59 @@ async function fetchOneRepoMeta(source) {
|
|
|
582
582
|
return null;
|
|
583
583
|
}
|
|
584
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Default-branch HEAD commit SHA per source — the signal we diff installed
|
|
587
|
+
* skills against. Cached with a much shorter TTL than the star/pushedAt meta
|
|
588
|
+
* (10 min vs 24h) so "update available" stays close to real-time without
|
|
589
|
+
* hammering the API; we only fetch it for collections that are actually
|
|
590
|
+
* installed. Cache only on success so a transient null never sticks.
|
|
591
|
+
*/
|
|
592
|
+
const repoHeadShaCache = new Map();
|
|
593
|
+
const REPO_HEAD_SHA_TTL_MS = 10 * 60 * 1000;
|
|
594
|
+
const repoHeadShaInflight = new Map();
|
|
595
|
+
async function fetchRepoHeadSha(source) {
|
|
596
|
+
const parsed = parseSourceToOwnerRepo(source);
|
|
597
|
+
if (!parsed)
|
|
598
|
+
return null;
|
|
599
|
+
const slug = `${parsed.owner}/${parsed.repo}`;
|
|
600
|
+
const cached = repoHeadShaCache.get(slug);
|
|
601
|
+
if (cached && Date.now() - cached.cachedAt < REPO_HEAD_SHA_TTL_MS)
|
|
602
|
+
return cached.value;
|
|
603
|
+
const inflight = repoHeadShaInflight.get(slug);
|
|
604
|
+
if (inflight)
|
|
605
|
+
return inflight;
|
|
606
|
+
const promise = (async () => {
|
|
607
|
+
try {
|
|
608
|
+
const controller = new AbortController();
|
|
609
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
610
|
+
const token = await resolveGithubToken();
|
|
611
|
+
// `commits?per_page=1` returns the latest commit on the default branch
|
|
612
|
+
// without needing to know the branch name first.
|
|
613
|
+
const res = await fetch(`https://api.github.com/repos/${slug}/commits?per_page=1`, {
|
|
614
|
+
signal: controller.signal,
|
|
615
|
+
headers: {
|
|
616
|
+
'Accept': 'application/vnd.github+json',
|
|
617
|
+
'User-Agent': 'pikiloom-dashboard',
|
|
618
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
619
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
clearTimeout(timer);
|
|
623
|
+
if (!res.ok)
|
|
624
|
+
return null;
|
|
625
|
+
const data = await res.json();
|
|
626
|
+
const sha = Array.isArray(data) && data[0]?.sha ? String(data[0].sha) : null;
|
|
627
|
+
if (sha)
|
|
628
|
+
repoHeadShaCache.set(slug, { value: sha, cachedAt: Date.now() });
|
|
629
|
+
return sha;
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
})().finally(() => repoHeadShaInflight.delete(slug));
|
|
635
|
+
repoHeadShaInflight.set(slug, promise);
|
|
636
|
+
return promise;
|
|
637
|
+
}
|
|
585
638
|
async function ensureRepoMeta(sources) {
|
|
586
639
|
const now = Date.now();
|
|
587
640
|
const stale = sources.filter(s => {
|
|
@@ -656,30 +709,56 @@ app.get('/api/extensions/skills/catalog', async (c) => {
|
|
|
656
709
|
const installedByName = new Map();
|
|
657
710
|
for (const s of scopedInstalled)
|
|
658
711
|
installedByName.set(s.name.toLowerCase(), s);
|
|
659
|
-
|
|
660
|
-
|
|
712
|
+
// Resolve each repo's installed sub-skills first (sync). Prefer the remote
|
|
713
|
+
// listing intersection over the static `repo.skills` hint — most catalog
|
|
714
|
+
// entries don't fill the hint, and the GitHub listing is authoritative.
|
|
715
|
+
const computeInstalledNames = (repo) => {
|
|
661
716
|
const remote = remoteSkillsCache.get(repo.source)?.value;
|
|
662
|
-
const owner = extractGithubOwner(repo.source);
|
|
663
|
-
const iconUrl = repo.iconUrl
|
|
664
|
-
?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
|
|
665
|
-
// Prefer the remote-listing intersection over the static `repo.skills` hint
|
|
666
|
-
// — most catalog entries don't bother filling the hint, and the GitHub
|
|
667
|
-
// listing is authoritative when cached.
|
|
668
|
-
let installedSkillNames;
|
|
669
717
|
if (remote) {
|
|
670
|
-
|
|
718
|
+
return remote.skills
|
|
671
719
|
.map(s => s.name)
|
|
672
720
|
.filter(name => installedByName.has(name.toLowerCase()));
|
|
673
721
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
722
|
+
const hints = (repo.skills || []).map(s => s.toLowerCase());
|
|
723
|
+
return scopedInstalled
|
|
724
|
+
.filter(s => hints.includes(s.name.toLowerCase()))
|
|
725
|
+
.map(s => s.name);
|
|
726
|
+
};
|
|
727
|
+
const perRepo = filtered.map(repo => ({ repo, installedNames: computeInstalledNames(repo) }));
|
|
728
|
+
// Update detection — only for installed collections (it's the only place the
|
|
729
|
+
// signal is meaningful, and it keeps API calls proportional to what's
|
|
730
|
+
// actually installed). Diff the live remote HEAD against the provenance
|
|
731
|
+
// ledger. For installs predating the ledger (e.g. via skills.sh) we baseline
|
|
732
|
+
// the current HEAD so future commits surface as updates, rather than
|
|
733
|
+
// fabricating one we can't prove.
|
|
734
|
+
const ledgerScope = scope === 'workspace' ? { workdir } : { global: true };
|
|
735
|
+
const updateBySource = new Map();
|
|
736
|
+
await Promise.all(perRepo.map(async ({ repo, installedNames }) => {
|
|
737
|
+
if (installedNames.length === 0)
|
|
738
|
+
return;
|
|
739
|
+
const latestSha = await fetchRepoHeadSha(repo.source).catch(() => null);
|
|
740
|
+
const entry = getSkillLedgerEntry(repo.source, ledgerScope);
|
|
741
|
+
let installedSha = entry?.sha ?? null;
|
|
742
|
+
if (!entry && latestSha) {
|
|
743
|
+
recordSkillInstall(repo.source, { ...ledgerScope, sha: latestSha, names: installedNames });
|
|
744
|
+
installedSha = latestSha;
|
|
679
745
|
}
|
|
680
|
-
|
|
681
|
-
|
|
746
|
+
updateBySource.set(repo.source, {
|
|
747
|
+
installedSha,
|
|
748
|
+
latestSha,
|
|
749
|
+
updateAvailable: !!(installedSha && latestSha && installedSha !== latestSha),
|
|
750
|
+
});
|
|
751
|
+
}));
|
|
752
|
+
const items = perRepo.map(({ repo, installedNames }) => {
|
|
753
|
+
const meta = githubMetaCache.get(repo.source)?.value;
|
|
754
|
+
const remote = remoteSkillsCache.get(repo.source)?.value;
|
|
755
|
+
const owner = extractGithubOwner(repo.source);
|
|
756
|
+
const iconUrl = repo.iconUrl
|
|
757
|
+
?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
|
|
758
|
+
const firstMatch = installedNames[0]
|
|
759
|
+
? installedByName.get(installedNames[0].toLowerCase())
|
|
682
760
|
: undefined;
|
|
761
|
+
const upd = updateBySource.get(repo.source);
|
|
683
762
|
return {
|
|
684
763
|
id: repo.id,
|
|
685
764
|
name: repo.name,
|
|
@@ -689,19 +768,27 @@ app.get('/api/extensions/skills/catalog', async (c) => {
|
|
|
689
768
|
category: repo.category,
|
|
690
769
|
recommendedScope: repo.recommendedScope,
|
|
691
770
|
homepage: repo.homepage,
|
|
692
|
-
installed:
|
|
771
|
+
installed: installedNames.length > 0,
|
|
693
772
|
scope: firstMatch?.scope,
|
|
694
|
-
installedNames
|
|
773
|
+
installedNames,
|
|
695
774
|
stars: meta?.stars,
|
|
696
775
|
pushedAt: meta?.pushedAt,
|
|
697
776
|
iconUrl,
|
|
698
777
|
totalCount: remote?.skills.length,
|
|
699
778
|
partial: remote?.partial,
|
|
779
|
+
pinned: repo.pinned,
|
|
780
|
+
updateAvailable: upd?.updateAvailable,
|
|
781
|
+
installedSha: upd?.installedSha,
|
|
782
|
+
latestSha: upd?.latestSha,
|
|
700
783
|
};
|
|
701
784
|
});
|
|
702
|
-
//
|
|
703
|
-
// sinking to the bottom so the most-loved repos surface first.
|
|
704
|
-
items.sort((a, b) =>
|
|
785
|
+
// Pinned first-party packs lead; then community popularity (stars desc, with
|
|
786
|
+
// no-data entries sinking to the bottom so the most-loved repos surface first).
|
|
787
|
+
items.sort((a, b) => {
|
|
788
|
+
if (!!a.pinned !== !!b.pinned)
|
|
789
|
+
return a.pinned ? -1 : 1;
|
|
790
|
+
return (b.stars ?? -1) - (a.stars ?? -1);
|
|
791
|
+
});
|
|
705
792
|
return c.json({ ok: true, items, installed });
|
|
706
793
|
});
|
|
707
794
|
/** POST /api/extensions/skills/install — install a skill via npx skills add. */
|
|
@@ -715,13 +802,39 @@ app.post('/api/extensions/skills/install', async (c) => {
|
|
|
715
802
|
if (!isGlobal && !isValidWorkdir(workdir)) {
|
|
716
803
|
return c.json({ ok: false, error: 'valid workdir is required for project-scoped install' }, 400);
|
|
717
804
|
}
|
|
718
|
-
|
|
805
|
+
// Resolve the remote HEAD so installSkill can record provenance for update
|
|
806
|
+
// detection. Best-effort — a failed lookup just installs without a baseline.
|
|
807
|
+
const sourceSha = await fetchRepoHeadSha(source.trim()).catch(() => null);
|
|
808
|
+
const result = await installSkill(source.trim(), { global: isGlobal, skill, workdir, sourceSha });
|
|
719
809
|
return c.json(result);
|
|
720
810
|
}
|
|
721
811
|
catch (e) {
|
|
722
812
|
return c.json({ ok: false, error: e?.message || 'installation failed' }, 500);
|
|
723
813
|
}
|
|
724
814
|
});
|
|
815
|
+
/**
|
|
816
|
+
* POST /api/extensions/skills/update — pull the latest version of an installed
|
|
817
|
+
* collection. Re-runs `skills add <source>` (no `-s`, so every skill from the
|
|
818
|
+
* source is refreshed) and advances the provenance ledger to the new HEAD.
|
|
819
|
+
*/
|
|
820
|
+
app.post('/api/extensions/skills/update', async (c) => {
|
|
821
|
+
try {
|
|
822
|
+
const body = await c.req.json();
|
|
823
|
+
const { source, global: isGlobal, workdir: reqWorkdir } = body;
|
|
824
|
+
if (!source?.trim())
|
|
825
|
+
return c.json({ ok: false, error: 'source is required' }, 400);
|
|
826
|
+
const workdir = reqWorkdir || runtime.getRequestWorkdir();
|
|
827
|
+
if (!isGlobal && !isValidWorkdir(workdir)) {
|
|
828
|
+
return c.json({ ok: false, error: 'valid workdir is required for project-scoped update' }, 400);
|
|
829
|
+
}
|
|
830
|
+
const sourceSha = await fetchRepoHeadSha(source.trim()).catch(() => null);
|
|
831
|
+
const result = await installSkill(source.trim(), { global: isGlobal, workdir, sourceSha });
|
|
832
|
+
return c.json({ ...result, sha: sourceSha });
|
|
833
|
+
}
|
|
834
|
+
catch (e) {
|
|
835
|
+
return c.json({ ok: false, error: e?.message || 'update failed' }, 500);
|
|
836
|
+
}
|
|
837
|
+
});
|
|
725
838
|
/** POST /api/extensions/skills/remove — remove an installed skill. */
|
|
726
839
|
app.post('/api/extensions/skills/remove', async (c) => {
|
|
727
840
|
try {
|