@venturewild/workspace 0.3.5 → 0.3.7
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/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +83 -83
- package/server/bin/wild-workspace.mjs +995 -995
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -453
- package/server/src/bazaar/core.mjs +579 -579
- package/server/src/bazaar/index.mjs +75 -75
- package/server/src/bazaar/mcp-server.mjs +328 -328
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
- package/server/src/canvas/core.mjs +421 -324
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +2475 -2349
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -0
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -0
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -0
- package/web/dist/assets/index-BxRx8EsD.js +91 -0
- package/web/dist/assets/index-DoOPBr3s.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DatlFPkm.js +0 -91
- package/web/dist/assets/index-Dl0VT5e6.css +0 -1
package/server/src/share.mjs
CHANGED
|
@@ -1,182 +1,182 @@
|
|
|
1
|
-
// Share-by-URL token issuance + verification (AR-20).
|
|
2
|
-
// Reuses bmo-sync invite shape: tokens are signed claims with role + workspace + expiry.
|
|
3
|
-
|
|
4
|
-
import { SignJWT, jwtVerify } from 'jose';
|
|
5
|
-
import { nanoid } from 'nanoid';
|
|
6
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
-
|
|
8
|
-
const SHARE_ISSUER = 'wild-workspace';
|
|
9
|
-
|
|
10
|
-
function secretToKey(secret) {
|
|
11
|
-
return new TextEncoder().encode(secret);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function mintShareToken({
|
|
15
|
-
secret,
|
|
16
|
-
workspaceId,
|
|
17
|
-
role,
|
|
18
|
-
ttlSeconds = 60 * 60 * 24, // 24h default per R17 mitigation
|
|
19
|
-
audience = 'wild-workspace-viewer',
|
|
20
|
-
subject = nanoid(12),
|
|
21
|
-
}) {
|
|
22
|
-
if (!['viewer', 'client'].includes(role)) {
|
|
23
|
-
throw new Error(`shareable role must be 'viewer' or 'client'; got ${role}`);
|
|
24
|
-
}
|
|
25
|
-
const key = secretToKey(secret);
|
|
26
|
-
const iat = Math.floor(Date.now() / 1000);
|
|
27
|
-
const exp = iat + ttlSeconds;
|
|
28
|
-
const jwt = await new SignJWT({
|
|
29
|
-
role,
|
|
30
|
-
workspaceId,
|
|
31
|
-
jti: nanoid(16),
|
|
32
|
-
})
|
|
33
|
-
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
34
|
-
.setIssuer(SHARE_ISSUER)
|
|
35
|
-
.setAudience(audience)
|
|
36
|
-
.setSubject(subject)
|
|
37
|
-
.setIssuedAt(iat)
|
|
38
|
-
.setExpirationTime(exp)
|
|
39
|
-
.sign(key);
|
|
40
|
-
return { token: jwt, exp, role, workspaceId, sub: subject };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Mint a PARTNER-role token for an approved device (Phase 2 device sign-in).
|
|
44
|
-
// Separate from `mintShareToken` on purpose: that path is least-privilege and
|
|
45
|
-
// must NEVER mint partner (its viewer/client guard is load-bearing). A device
|
|
46
|
-
// token is full owner access, so it's bounded (90d default) and individually
|
|
47
|
-
// revocable via `TokenRegistry` (its `device-` subject is registered at approve
|
|
48
|
-
// time). It verifies through the same `verifyShareToken` path — `classifyToken`
|
|
49
|
-
// then resolves `role:'partner'` exactly like the env partner token, but with a
|
|
50
|
-
// revocable `sub`.
|
|
51
|
-
export async function mintDeviceToken({
|
|
52
|
-
secret,
|
|
53
|
-
workspaceId,
|
|
54
|
-
ttlSeconds = 90 * 24 * 60 * 60, // 90 days — bounded RCE-grade token
|
|
55
|
-
audience = 'wild-workspace-device',
|
|
56
|
-
subject = `device-${nanoid(12)}`,
|
|
57
|
-
}) {
|
|
58
|
-
const key = secretToKey(secret);
|
|
59
|
-
const iat = Math.floor(Date.now() / 1000);
|
|
60
|
-
const exp = iat + ttlSeconds;
|
|
61
|
-
const jwt = await new SignJWT({
|
|
62
|
-
role: 'partner',
|
|
63
|
-
workspaceId,
|
|
64
|
-
jti: nanoid(16),
|
|
65
|
-
})
|
|
66
|
-
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
67
|
-
.setIssuer(SHARE_ISSUER)
|
|
68
|
-
.setAudience(audience)
|
|
69
|
-
.setSubject(subject)
|
|
70
|
-
.setIssuedAt(iat)
|
|
71
|
-
.setExpirationTime(exp)
|
|
72
|
-
.sign(key);
|
|
73
|
-
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Mint a one-time-ish BOOTSTRAP token for the first-device sign-in on the public
|
|
77
|
-
// URL (B1). The local launcher fetches this over genuine loopback and opens
|
|
78
|
-
// <slug>.venturewild.llc/?t=<token>; the SPA exchanges it for a DURABLE device
|
|
79
|
-
// cookie (see /api/auth/exchange) and strips it from the address bar. It is
|
|
80
|
-
// partner-role but deliberately SHORT-LIVED (5 min default) so its only window
|
|
81
|
-
// of exposure — one navigation through the VW tunnel — is tightly bounded; the
|
|
82
|
-
// long-lived credential the browser keeps is the re-minted device token, never
|
|
83
|
-
// this. Carries `boot:true` so the exchange path can recognise + upgrade it.
|
|
84
|
-
export async function mintBootstrapToken({
|
|
85
|
-
secret,
|
|
86
|
-
workspaceId,
|
|
87
|
-
ttlSeconds = 5 * 60, // 5 minutes — bounds the token-in-URL window
|
|
88
|
-
audience = 'wild-workspace-bootstrap',
|
|
89
|
-
subject = `bootstrap-${nanoid(12)}`,
|
|
90
|
-
}) {
|
|
91
|
-
const key = secretToKey(secret);
|
|
92
|
-
const iat = Math.floor(Date.now() / 1000);
|
|
93
|
-
const exp = iat + ttlSeconds;
|
|
94
|
-
const jwt = await new SignJWT({
|
|
95
|
-
role: 'partner',
|
|
96
|
-
workspaceId,
|
|
97
|
-
boot: true,
|
|
98
|
-
jti: nanoid(16),
|
|
99
|
-
})
|
|
100
|
-
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
101
|
-
.setIssuer(SHARE_ISSUER)
|
|
102
|
-
.setAudience(audience)
|
|
103
|
-
.setSubject(subject)
|
|
104
|
-
.setIssuedAt(iat)
|
|
105
|
-
.setExpirationTime(exp)
|
|
106
|
-
.sign(key);
|
|
107
|
-
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export async function verifyShareToken(token, secret) {
|
|
111
|
-
if (!token) return null;
|
|
112
|
-
try {
|
|
113
|
-
const { payload } = await jwtVerify(token, secretToKey(secret), {
|
|
114
|
-
issuer: SHARE_ISSUER,
|
|
115
|
-
});
|
|
116
|
-
return payload;
|
|
117
|
-
} catch {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
|
|
123
|
-
const base = shareBaseUrl.replace(/\/$/, '');
|
|
124
|
-
return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Token registry so the partner can list + revoke. The revocation set (and the
|
|
128
|
-
// active-token list) is persisted to `<dataDir>/revoked.json` so a "revoked"
|
|
129
|
-
// share token stays revoked across a server restart (concern C8) — without it,
|
|
130
|
-
// the in-memory set reset on restart and a previously-revoked-but-unexpired JWT
|
|
131
|
-
// validated again by signature. No `persistPath` → in-memory only (tests).
|
|
132
|
-
export class TokenRegistry {
|
|
133
|
-
constructor({ persistPath = null } = {}) {
|
|
134
|
-
this.persistPath = persistPath;
|
|
135
|
-
this.tokens = new Map(); // sub -> { sub, role, workspaceId, exp, label, createdAt }
|
|
136
|
-
this.revoked = new Set();
|
|
137
|
-
this._load();
|
|
138
|
-
}
|
|
139
|
-
_load() {
|
|
140
|
-
if (!this.persistPath) return;
|
|
141
|
-
try {
|
|
142
|
-
const data = JSON.parse(readFileSync(this.persistPath, 'utf8'));
|
|
143
|
-
if (Array.isArray(data.revoked)) this.revoked = new Set(data.revoked);
|
|
144
|
-
if (Array.isArray(data.tokens)) {
|
|
145
|
-
for (const t of data.tokens) if (t?.sub) this.tokens.set(t.sub, t);
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
/* missing / corrupt — start empty */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
_persist() {
|
|
152
|
-
if (!this.persistPath) return;
|
|
153
|
-
try {
|
|
154
|
-
const now = Math.floor(Date.now() / 1000);
|
|
155
|
-
// Don't carry expired tokens forward; the revoked set stays (small).
|
|
156
|
-
const tokens = [...this.tokens.values()].filter((r) => r.exp > now);
|
|
157
|
-
writeFileSync(
|
|
158
|
-
this.persistPath,
|
|
159
|
-
JSON.stringify({ revoked: [...this.revoked], tokens }, null, 2),
|
|
160
|
-
{ mode: 0o600 },
|
|
161
|
-
);
|
|
162
|
-
} catch {
|
|
163
|
-
/* read-only fs — revocation degrades to in-memory for this run */
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
add(record) {
|
|
167
|
-
this.tokens.set(record.sub, record);
|
|
168
|
-
this._persist();
|
|
169
|
-
}
|
|
170
|
-
list() {
|
|
171
|
-
const now = Math.floor(Date.now() / 1000);
|
|
172
|
-
return [...this.tokens.values()].filter((r) => r.exp > now && !this.revoked.has(r.sub));
|
|
173
|
-
}
|
|
174
|
-
revoke(sub) {
|
|
175
|
-
this.revoked.add(sub);
|
|
176
|
-
this.tokens.delete(sub);
|
|
177
|
-
this._persist();
|
|
178
|
-
}
|
|
179
|
-
isRevoked(sub) {
|
|
180
|
-
return this.revoked.has(sub);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
1
|
+
// Share-by-URL token issuance + verification (AR-20).
|
|
2
|
+
// Reuses bmo-sync invite shape: tokens are signed claims with role + workspace + expiry.
|
|
3
|
+
|
|
4
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
5
|
+
import { nanoid } from 'nanoid';
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const SHARE_ISSUER = 'wild-workspace';
|
|
9
|
+
|
|
10
|
+
function secretToKey(secret) {
|
|
11
|
+
return new TextEncoder().encode(secret);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function mintShareToken({
|
|
15
|
+
secret,
|
|
16
|
+
workspaceId,
|
|
17
|
+
role,
|
|
18
|
+
ttlSeconds = 60 * 60 * 24, // 24h default per R17 mitigation
|
|
19
|
+
audience = 'wild-workspace-viewer',
|
|
20
|
+
subject = nanoid(12),
|
|
21
|
+
}) {
|
|
22
|
+
if (!['viewer', 'client'].includes(role)) {
|
|
23
|
+
throw new Error(`shareable role must be 'viewer' or 'client'; got ${role}`);
|
|
24
|
+
}
|
|
25
|
+
const key = secretToKey(secret);
|
|
26
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
27
|
+
const exp = iat + ttlSeconds;
|
|
28
|
+
const jwt = await new SignJWT({
|
|
29
|
+
role,
|
|
30
|
+
workspaceId,
|
|
31
|
+
jti: nanoid(16),
|
|
32
|
+
})
|
|
33
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
34
|
+
.setIssuer(SHARE_ISSUER)
|
|
35
|
+
.setAudience(audience)
|
|
36
|
+
.setSubject(subject)
|
|
37
|
+
.setIssuedAt(iat)
|
|
38
|
+
.setExpirationTime(exp)
|
|
39
|
+
.sign(key);
|
|
40
|
+
return { token: jwt, exp, role, workspaceId, sub: subject };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mint a PARTNER-role token for an approved device (Phase 2 device sign-in).
|
|
44
|
+
// Separate from `mintShareToken` on purpose: that path is least-privilege and
|
|
45
|
+
// must NEVER mint partner (its viewer/client guard is load-bearing). A device
|
|
46
|
+
// token is full owner access, so it's bounded (90d default) and individually
|
|
47
|
+
// revocable via `TokenRegistry` (its `device-` subject is registered at approve
|
|
48
|
+
// time). It verifies through the same `verifyShareToken` path — `classifyToken`
|
|
49
|
+
// then resolves `role:'partner'` exactly like the env partner token, but with a
|
|
50
|
+
// revocable `sub`.
|
|
51
|
+
export async function mintDeviceToken({
|
|
52
|
+
secret,
|
|
53
|
+
workspaceId,
|
|
54
|
+
ttlSeconds = 90 * 24 * 60 * 60, // 90 days — bounded RCE-grade token
|
|
55
|
+
audience = 'wild-workspace-device',
|
|
56
|
+
subject = `device-${nanoid(12)}`,
|
|
57
|
+
}) {
|
|
58
|
+
const key = secretToKey(secret);
|
|
59
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
60
|
+
const exp = iat + ttlSeconds;
|
|
61
|
+
const jwt = await new SignJWT({
|
|
62
|
+
role: 'partner',
|
|
63
|
+
workspaceId,
|
|
64
|
+
jti: nanoid(16),
|
|
65
|
+
})
|
|
66
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
67
|
+
.setIssuer(SHARE_ISSUER)
|
|
68
|
+
.setAudience(audience)
|
|
69
|
+
.setSubject(subject)
|
|
70
|
+
.setIssuedAt(iat)
|
|
71
|
+
.setExpirationTime(exp)
|
|
72
|
+
.sign(key);
|
|
73
|
+
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Mint a one-time-ish BOOTSTRAP token for the first-device sign-in on the public
|
|
77
|
+
// URL (B1). The local launcher fetches this over genuine loopback and opens
|
|
78
|
+
// <slug>.venturewild.llc/?t=<token>; the SPA exchanges it for a DURABLE device
|
|
79
|
+
// cookie (see /api/auth/exchange) and strips it from the address bar. It is
|
|
80
|
+
// partner-role but deliberately SHORT-LIVED (5 min default) so its only window
|
|
81
|
+
// of exposure — one navigation through the VW tunnel — is tightly bounded; the
|
|
82
|
+
// long-lived credential the browser keeps is the re-minted device token, never
|
|
83
|
+
// this. Carries `boot:true` so the exchange path can recognise + upgrade it.
|
|
84
|
+
export async function mintBootstrapToken({
|
|
85
|
+
secret,
|
|
86
|
+
workspaceId,
|
|
87
|
+
ttlSeconds = 5 * 60, // 5 minutes — bounds the token-in-URL window
|
|
88
|
+
audience = 'wild-workspace-bootstrap',
|
|
89
|
+
subject = `bootstrap-${nanoid(12)}`,
|
|
90
|
+
}) {
|
|
91
|
+
const key = secretToKey(secret);
|
|
92
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
93
|
+
const exp = iat + ttlSeconds;
|
|
94
|
+
const jwt = await new SignJWT({
|
|
95
|
+
role: 'partner',
|
|
96
|
+
workspaceId,
|
|
97
|
+
boot: true,
|
|
98
|
+
jti: nanoid(16),
|
|
99
|
+
})
|
|
100
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
101
|
+
.setIssuer(SHARE_ISSUER)
|
|
102
|
+
.setAudience(audience)
|
|
103
|
+
.setSubject(subject)
|
|
104
|
+
.setIssuedAt(iat)
|
|
105
|
+
.setExpirationTime(exp)
|
|
106
|
+
.sign(key);
|
|
107
|
+
return { token: jwt, exp, role: 'partner', workspaceId, sub: subject };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function verifyShareToken(token, secret) {
|
|
111
|
+
if (!token) return null;
|
|
112
|
+
try {
|
|
113
|
+
const { payload } = await jwtVerify(token, secretToKey(secret), {
|
|
114
|
+
issuer: SHARE_ISSUER,
|
|
115
|
+
});
|
|
116
|
+
return payload;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
|
|
123
|
+
const base = shareBaseUrl.replace(/\/$/, '');
|
|
124
|
+
return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Token registry so the partner can list + revoke. The revocation set (and the
|
|
128
|
+
// active-token list) is persisted to `<dataDir>/revoked.json` so a "revoked"
|
|
129
|
+
// share token stays revoked across a server restart (concern C8) — without it,
|
|
130
|
+
// the in-memory set reset on restart and a previously-revoked-but-unexpired JWT
|
|
131
|
+
// validated again by signature. No `persistPath` → in-memory only (tests).
|
|
132
|
+
export class TokenRegistry {
|
|
133
|
+
constructor({ persistPath = null } = {}) {
|
|
134
|
+
this.persistPath = persistPath;
|
|
135
|
+
this.tokens = new Map(); // sub -> { sub, role, workspaceId, exp, label, createdAt }
|
|
136
|
+
this.revoked = new Set();
|
|
137
|
+
this._load();
|
|
138
|
+
}
|
|
139
|
+
_load() {
|
|
140
|
+
if (!this.persistPath) return;
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(readFileSync(this.persistPath, 'utf8'));
|
|
143
|
+
if (Array.isArray(data.revoked)) this.revoked = new Set(data.revoked);
|
|
144
|
+
if (Array.isArray(data.tokens)) {
|
|
145
|
+
for (const t of data.tokens) if (t?.sub) this.tokens.set(t.sub, t);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
/* missing / corrupt — start empty */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
_persist() {
|
|
152
|
+
if (!this.persistPath) return;
|
|
153
|
+
try {
|
|
154
|
+
const now = Math.floor(Date.now() / 1000);
|
|
155
|
+
// Don't carry expired tokens forward; the revoked set stays (small).
|
|
156
|
+
const tokens = [...this.tokens.values()].filter((r) => r.exp > now);
|
|
157
|
+
writeFileSync(
|
|
158
|
+
this.persistPath,
|
|
159
|
+
JSON.stringify({ revoked: [...this.revoked], tokens }, null, 2),
|
|
160
|
+
{ mode: 0o600 },
|
|
161
|
+
);
|
|
162
|
+
} catch {
|
|
163
|
+
/* read-only fs — revocation degrades to in-memory for this run */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
add(record) {
|
|
167
|
+
this.tokens.set(record.sub, record);
|
|
168
|
+
this._persist();
|
|
169
|
+
}
|
|
170
|
+
list() {
|
|
171
|
+
const now = Math.floor(Date.now() / 1000);
|
|
172
|
+
return [...this.tokens.values()].filter((r) => r.exp > now && !this.revoked.has(r.sub));
|
|
173
|
+
}
|
|
174
|
+
revoke(sub) {
|
|
175
|
+
this.revoked.add(sub);
|
|
176
|
+
this.tokens.delete(sub);
|
|
177
|
+
this._persist();
|
|
178
|
+
}
|
|
179
|
+
isRevoked(sub) {
|
|
180
|
+
return this.revoked.has(sub);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Skill discovery + the Powers-block curation store (§7/§8). There is NO native
|
|
2
|
+
// "list commands" API in Claude Code, so we discover the user's skills the cleanest
|
|
3
|
+
// way the design settled on: SCAN the skill directories and parse YAML frontmatter.
|
|
4
|
+
//
|
|
5
|
+
// - project skills: <workspace>/.claude/skills/<name>/SKILL.md
|
|
6
|
+
// - project commands: <workspace>/.claude/commands/<name>.md
|
|
7
|
+
// - user skills: ~/.claude/skills/<name>/SKILL.md
|
|
8
|
+
//
|
|
9
|
+
// Precedence (per §2): project > user. The Powers block is USER-CURATED — we surface
|
|
10
|
+
// what the user has (their own + a teammate's, both trusted by membership) with NO
|
|
11
|
+
// VW-prescribed core forced. The "─ from Bazaar ─" zone stays empty/LOCKED until the
|
|
12
|
+
// Shelf trust/provenance Class-D gate ships (a tap runs third-party instructions with
|
|
13
|
+
// bypassPermissions), so this module never returns Bazaar skills.
|
|
14
|
+
//
|
|
15
|
+
// Invocation is NOT done here: a Powers tap injects `/<name>` as a chat message, which
|
|
16
|
+
// Claude Code expands+runs headless (verified §2) — the same stdin path we already use.
|
|
17
|
+
//
|
|
18
|
+
// Curation (which skills are pinned/hidden + their order) lives under
|
|
19
|
+
// ~/.wild-workspace/powers/ (CLAUDE.md #1 — never the synced repo, never localStorage).
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
|
|
25
|
+
// Bounds so a workspace stuffed with skills can't bloat the store/UI.
|
|
26
|
+
const CAP = { skills: 80, name: 64, desc: 280 };
|
|
27
|
+
|
|
28
|
+
// --- frontmatter parse -----------------------------------------------------
|
|
29
|
+
// Minimal, dependency-free. We only need a few scalar keys; the description often
|
|
30
|
+
// CONTAINS colons ("Usage: /foo"), so we split on the FIRST colon only. Never throws.
|
|
31
|
+
|
|
32
|
+
export function parseFrontmatter(text = '') {
|
|
33
|
+
const out = {};
|
|
34
|
+
if (typeof text !== 'string') return out;
|
|
35
|
+
// Frontmatter is the block between the first two `---` fences at the top.
|
|
36
|
+
const m = text.match(/^?---\s*\r?\n([\s\S]*?)\r?\n---/);
|
|
37
|
+
if (!m) return out;
|
|
38
|
+
for (const rawLine of m[1].split(/\r?\n/)) {
|
|
39
|
+
const line = rawLine.trim();
|
|
40
|
+
if (!line || line.startsWith('#')) continue;
|
|
41
|
+
const i = line.indexOf(':');
|
|
42
|
+
if (i < 0) continue;
|
|
43
|
+
const key = line.slice(0, i).trim();
|
|
44
|
+
let val = line.slice(i + 1).trim();
|
|
45
|
+
// strip matching surrounding quotes
|
|
46
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
47
|
+
val = val.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
out[key] = val;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asBool(v, dflt) {
|
|
55
|
+
if (v === undefined) return dflt;
|
|
56
|
+
const s = String(v).trim().toLowerCase();
|
|
57
|
+
if (s === 'true' || s === 'yes' || s === '1') return true;
|
|
58
|
+
if (s === 'false' || s === 'no' || s === '0') return false;
|
|
59
|
+
return dflt;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clip(s, n) {
|
|
63
|
+
return String(s || '').slice(0, n);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build one skill record from a parsed frontmatter + its fallback name.
|
|
67
|
+
function toSkill(fm, fallbackName, source) {
|
|
68
|
+
const name = clip(fm.name || fallbackName, CAP.name).trim();
|
|
69
|
+
if (!name) return null;
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
description: clip(fm.description || '', CAP.desc).trim(),
|
|
73
|
+
source, // 'project' | 'user'
|
|
74
|
+
// user-invocable defaults TRUE (most skills can be run as /name); only an explicit
|
|
75
|
+
// `user-invocable: false` opts out of being a tappable Power.
|
|
76
|
+
userInvocable: asBool(fm['user-invocable'], true),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFileSafe(file) {
|
|
81
|
+
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listDirs(dir) {
|
|
85
|
+
try {
|
|
86
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
87
|
+
.filter((e) => e.isDirectory())
|
|
88
|
+
.map((e) => e.name);
|
|
89
|
+
} catch { return []; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function listFiles(dir, ext) {
|
|
93
|
+
try {
|
|
94
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
95
|
+
.filter((e) => e.isFile() && e.name.endsWith(ext))
|
|
96
|
+
.map((e) => e.name);
|
|
97
|
+
} catch { return []; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Scan one skills root (<root>/<name>/SKILL.md) → skill records.
|
|
101
|
+
function scanSkillsRoot(root, source) {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const name of listDirs(root)) {
|
|
104
|
+
const text = readFileSafe(path.join(root, name, 'SKILL.md'));
|
|
105
|
+
if (text == null) continue;
|
|
106
|
+
const s = toSkill(parseFrontmatter(text), name, source);
|
|
107
|
+
if (s) out.push(s);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Scan a commands root (<root>/<name>.md) → skill records.
|
|
113
|
+
function scanCommandsRoot(root, source) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const file of listFiles(root, '.md')) {
|
|
116
|
+
const text = readFileSafe(path.join(root, file));
|
|
117
|
+
if (text == null) continue;
|
|
118
|
+
const s = toSkill(parseFrontmatter(text), file.replace(/\.md$/, ''), source);
|
|
119
|
+
if (s) out.push(s);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Discover the workspace's skills. Project entries take precedence over same-named
|
|
126
|
+
* user entries (§2). Returns ONLY user-invocable skills (the Powers block taps run
|
|
127
|
+
* them as /name), capped. Never throws — a missing dir just yields fewer skills.
|
|
128
|
+
*/
|
|
129
|
+
export function discoverSkills({ workspaceDir = process.cwd(), home = os.homedir() } = {}) {
|
|
130
|
+
const project = [
|
|
131
|
+
...scanSkillsRoot(path.join(workspaceDir, '.claude', 'skills'), 'project'),
|
|
132
|
+
...scanCommandsRoot(path.join(workspaceDir, '.claude', 'commands'), 'project'),
|
|
133
|
+
];
|
|
134
|
+
const user = scanSkillsRoot(path.join(home, '.claude', 'skills'), 'user');
|
|
135
|
+
|
|
136
|
+
// Dedup by name, project wins.
|
|
137
|
+
const byName = new Map();
|
|
138
|
+
for (const s of user) byName.set(s.name, s);
|
|
139
|
+
for (const s of project) byName.set(s.name, s); // overrides user
|
|
140
|
+
return [...byName.values()]
|
|
141
|
+
.filter((s) => s.userInvocable)
|
|
142
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
143
|
+
.slice(0, CAP.skills);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- curation store --------------------------------------------------------
|
|
147
|
+
// The user's arrangement: `order` (pinned, in display order) + `hidden` (subtracted).
|
|
148
|
+
// A discovered skill not in either is shown after the pinned ones (so a NEW skill
|
|
149
|
+
// appears without the user having to add it — "add/subtract/arrange", subtract is opt-in).
|
|
150
|
+
|
|
151
|
+
export function defaultPowersDir(env = process.env, home = os.homedir()) {
|
|
152
|
+
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
|
|
153
|
+
return path.join(base, 'powers');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readJsonSafe(file, fallback) {
|
|
157
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createPowers({ baseDir, workspaceDir, home = os.homedir(), env = process.env } = {}) {
|
|
161
|
+
const dir = baseDir || defaultPowersDir(env, home);
|
|
162
|
+
const curationFile = path.join(dir, 'curation.json');
|
|
163
|
+
|
|
164
|
+
function getCuration() {
|
|
165
|
+
const v = readJsonSafe(curationFile, null);
|
|
166
|
+
const order = Array.isArray(v?.order) ? v.order.filter((s) => typeof s === 'string') : [];
|
|
167
|
+
const hidden = Array.isArray(v?.hidden) ? v.hidden.filter((s) => typeof s === 'string') : [];
|
|
168
|
+
return { order, hidden };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function saveCuration(raw = {}) {
|
|
172
|
+
const order = Array.isArray(raw.order)
|
|
173
|
+
? [...new Set(raw.order.filter((s) => typeof s === 'string').map((s) => s.slice(0, CAP.name)))].slice(0, CAP.skills)
|
|
174
|
+
: [];
|
|
175
|
+
const hidden = Array.isArray(raw.hidden)
|
|
176
|
+
? [...new Set(raw.hidden.filter((s) => typeof s === 'string').map((s) => s.slice(0, CAP.name)))].slice(0, CAP.skills)
|
|
177
|
+
: [];
|
|
178
|
+
const data = { order, hidden, ts: Date.now() };
|
|
179
|
+
try {
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
181
|
+
const tmp = `${curationFile}.${process.pid}.tmp`;
|
|
182
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
183
|
+
fs.renameSync(tmp, curationFile);
|
|
184
|
+
} catch { /* read-only fs — degrade to not-persisted */ }
|
|
185
|
+
return data;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The merged view the UI renders: discovered skills, ordered by curation, with a
|
|
189
|
+
// `hidden` flag. Pinned (in `order`) first, then the rest alphabetically.
|
|
190
|
+
function state() {
|
|
191
|
+
const discovered = discoverSkills({ workspaceDir, home });
|
|
192
|
+
const { order, hidden } = getCuration();
|
|
193
|
+
const hiddenSet = new Set(hidden);
|
|
194
|
+
const rank = new Map(order.map((n, i) => [n, i]));
|
|
195
|
+
const skills = discovered
|
|
196
|
+
.map((s) => ({ ...s, hidden: hiddenSet.has(s.name) }))
|
|
197
|
+
.sort((a, b) => {
|
|
198
|
+
const ra = rank.has(a.name) ? rank.get(a.name) : Infinity;
|
|
199
|
+
const rb = rank.has(b.name) ? rank.get(b.name) : Infinity;
|
|
200
|
+
if (ra !== rb) return ra - rb;
|
|
201
|
+
return a.name.localeCompare(b.name);
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
skills,
|
|
205
|
+
curation: { order, hidden },
|
|
206
|
+
// The marketplace zone is specified but GATED (Class-D, not built) — always
|
|
207
|
+
// empty + locked here so the UI can render the "─ from Bazaar ─" boundary.
|
|
208
|
+
bazaar: { locked: true, skills: [] },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { dir, curationFile, getCuration, saveCuration, state };
|
|
213
|
+
}
|