Compare commits

..
Author SHA1 Message Date
momothemage ef8d53fa5f fix: drop unrelated conflict carryover from PR #1879 2026-05-07 10:42:51 +08:00
momothemage f6661a7a4c fix(publish): allow grandfathered slugs to update in insertVersion mutations 2026-04-29 12:13:40 +08:00
momothemage d2c2e7b509 style: re-apply oxfmt to packages.public.test.ts after upstream merge 2026-04-29 11:57:34 +08:00
momothemage 70f6a435e2 Merge remote-tracking branch 'upstream/main' into feature/fix_slug_limit
# Conflicts:
#	src/__tests__/header.test.tsx
2026-04-29 11:56:28 +08:00
momothemage 929c9de50e style: format files touched in CI format-check scope
CI computes the PR format-check scope using two-dot diff between the
pull request base SHA (captured at PR open time) and the head SHA, so
any file changed in upstream/main after the PR was opened but before
the PR was merged falls into scope and gets oxfmt --check'd. The three
src/ files below were introduced by upstream PR #1873 (skill upload)
without oxfmt formatting, so they fail CI format:check on this branch
even though they are not logically part of the slug validation fix.

Run oxfmt --write on them to satisfy CI. No behaviour change.

Affected files:
- src/__tests__/header.test.tsx
- src/components/Header.tsx
- src/routes/settings.tsx
2026-04-29 11:53:57 +08:00
momothemage ea67295794 fix(slug): keep read & update paths working for grandfathered slugs 2026-04-29 11:44:39 +08:00
momothemage f5a3cba962 fix(search): restore exact-slug lookup for legacy short slugs 2026-04-29 11:30:15 +08:00
momothemage b2a173ab75 style: apply oxfmt formatting to slug validation PR files 2026-04-29 11:19:21 +08:00
Patrick Erichsen 5d01b99adb Merge pull request #1878 from openclaw/pe/clawhub-rescan-guidance
feat: add ClawHub rescan guidance workflow
2026-04-28 20:09:30 -07:00
Patrick Erichsen 5dc834c27e feat: add ClawHub rescan guidance workflow 2026-04-28 20:07:52 -07:00
momothemage 2e74c9bad2 fix(slug): tighten skill/soul slug validation with length limits and reserved-word blocklist 2026-04-29 10:56:19 +08:00
Patrick Erichsen 82b9a69dad Merge pull request #1875 from openclaw/pe/settings-stars
fix: move stars link into settings
2026-04-28 19:50:45 -07:00
Vincent Koc 064804e2d3 fix: make package publish retries idempotent 2026-04-28 19:29:39 -07:00
Patrick Erichsen 04a862d2b2 fix: move stars link into settings 2026-04-28 18:32:09 -07:00
Patrick Erichsen a7fc4bbae2 Merge pull request #1874 from openclaw/pe/oxfmt-pr-check
ci: check oxfmt on pull requests
2026-04-28 18:22:56 -07:00
Patrick Erichsen 4701c555f3 ci: check oxfmt on pull requests 2026-04-28 18:16:32 -07:00
Patrick Erichsen c1f167721b Merge pull request #1873 from openclaw/pe/fix-skill-upload
fix: add skill upload button to header
2026-04-28 17:37:14 -07:00
Patrick Erichsen 1a94744484 Update $name.tsx 2026-04-28 17:37:02 -07:00
Patrick Erichsen 9a5cfeee85 Update SkillHeader.tsx 2026-04-28 17:29:14 -07:00
Patrick Erichsen e69b7d4501 fix: add skill upload button to header 2026-04-28 17:25:30 -07:00
Patrick Erichsen ecf09b868a Merge pull request #1872 from openclaw/pe/clawhub-cli-0.12.1
chore(release): prepare clawhub cli 0.12.1
2026-04-28 16:53:37 -07:00
Patrick Erichsen 4d16472f5b chore(release): prepare clawhub cli 0.12.0 2026-04-28 16:52:53 -07:00
31 changed files with 2473 additions and 919 deletions
+20
View File
@@ -12,6 +12,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
with:
@@ -22,6 +24,24 @@ jobs:
- name: Peer deps
run: bun run check:peers
- name: Format
if: github.event_name == 'pull_request'
run: |
mapfile -d '' changed_files < <(
git diff --name-only --diff-filter=ACMR -z \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" \
-- \
'*.css' '*.js' '*.jsx' '*.json' '*.md' '*.mjs' '*.ts' '*.tsx' '*.yaml' '*.yml'
)
if (( ${#changed_files[@]} == 0 )); then
echo "No changed files supported by oxfmt."
exit 0
fi
bun run format:check -- "${changed_files[@]}"
- name: Lint
run: bun run lint
@@ -0,0 +1,38 @@
name: ClawHub Rescan Guidance
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number to check"
required: true
type: string
permissions:
contents: read
issues: write
concurrency:
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
cancel-in-progress: false
jobs:
rescan-guidance:
runs-on: ubuntu-latest
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
env:
GH_TOKEN: ${{ github.token }}
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
steps:
- uses: actions/checkout@v4
- name: Comment when rescan guidance label is present
run: |
node scripts/github/clawhub-rescan-auto-response.mjs \
--repo "$GITHUB_REPOSITORY" \
--issue "$ISSUE_NUMBER" \
--comment-for-labeled-issue \
--apply
+1 -1
View File
@@ -94,7 +94,7 @@
},
"packages/clawhub": {
"name": "clawhub",
"version": "0.11.0",
"version": "0.12.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js",
+21 -7
View File
@@ -1,5 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -34,6 +34,7 @@ import {
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSkillSummary } from "./skillSummary";
import { runStaticPublishScan } from "./staticPublishScan";
import type { WebhookSkillPayload } from "./webhooks";
@@ -90,12 +91,16 @@ export async function publishVersionForUser(
options: PublishOptions = {},
): Promise<PublishResult> {
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
// Normalize first so we can look up the existing skill before deciding
// how strictly to validate. The reserved-word blocklist and length floor
// are only enforced for brand-new skills; owners of grandfathered slugs
// (reserved, <3 chars, or >48 chars) must still be able to publish new
// versions without being blocked by the write-path validator.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
@@ -104,10 +109,19 @@ export async function publishVersionForUser(
await requireGitHubAccountAge(ctx, userId);
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
slug: normalizedSlug,
})) as Doc<"skills"> | null;
const isNewSkill = !existingSkill;
// For new skills, enforce the full write-path rules (length, pattern,
// reserved-word blocklist). For existing skills the slug is already
// persisted and grandfathered — re-validating it would block legitimate
// version publishes on legacy rows.
if (isNewSkill) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
assertValidSkillSlug,
isReservedSkillSlug,
isSearchableSkillSlugShape,
isValidSkillSlugShape,
normalizeSkillSlug,
normalizeSkillSlugOrNull,
SKILL_SLUG_CONSTRAINTS,
} from "./skillSlugValidator";
describe("normalizeSkillSlug", () => {
it("trims and lowercases", () => {
expect(normalizeSkillSlug(" Hello-World ")).toBe("hello-world");
});
it("returns empty string for nullish", () => {
expect(normalizeSkillSlug(undefined)).toBe("");
expect(normalizeSkillSlug(null)).toBe("");
});
});
describe("normalizeSkillSlugOrNull", () => {
it("returns null for empty input", () => {
expect(normalizeSkillSlugOrNull(" ")).toBeNull();
expect(normalizeSkillSlugOrNull(null)).toBeNull();
});
it("returns normalized slug for non-empty input", () => {
expect(normalizeSkillSlugOrNull(" MySkill ")).toBe("myskill");
});
});
describe("assertValidSkillSlug", () => {
it.each([
"abc",
"my-cool-skill",
"skill-123",
"a1b",
"123",
"abc-def-ghi",
"z".repeat(SKILL_SLUG_CONSTRAINTS.maxLength),
])("accepts valid slug %s", (slug) => {
expect(() => assertValidSkillSlug(slug)).not.toThrow();
expect(assertValidSkillSlug(slug)).toBe(slug.toLowerCase());
});
it("normalizes mixed case and whitespace before validating", () => {
expect(assertValidSkillSlug(" My-Cool-Skill ")).toBe("my-cool-skill");
});
it("silently lowercases uppercase input (legacy-compatible)", () => {
// Historically, write paths did `args.slug.trim().toLowerCase()` before
// validating. We preserve that behaviour: uppercase input is normalized,
// not rejected outright.
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
});
it.each([
["", "required"],
[" ", "required"],
["ab", "at least"],
["a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1), "at most"],
["-abc", "start and end"],
["abc-", "start and end"],
["a--b", "start and end"],
["a---b", "start and end"],
["a_b", "start and end"],
["a.b", "start and end"],
["a b", "start and end"],
["a/b", "start and end"],
])("rejects invalid slug %s", (slug, hint) => {
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
});
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages"])(
"rejects reserved slug %s",
(slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
// length rule; we only assert that a throw happens for every entry.
expect(() => assertValidSkillSlug(slug)).toThrow();
},
);
it("emits the reserved-specific error for long reserved slugs", () => {
expect(() => assertValidSkillSlug("openclaw")).toThrow(/reserved/i);
});
it("allows reserved slugs when allowReserved is set", () => {
expect(() => assertValidSkillSlug("admin", { allowReserved: true })).not.toThrow();
expect(assertValidSkillSlug("admin", { allowReserved: true })).toBe("admin");
});
});
describe("isValidSkillSlugShape", () => {
it("returns true for well-formed slugs", () => {
expect(isValidSkillSlugShape("abc")).toBe(true);
expect(isValidSkillSlugShape("my-skill-1")).toBe(true);
});
it("returns true for reserved slugs (shape only)", () => {
// The reserved-word blocklist is intentionally NOT consulted here so
// that legacy rows carrying reserved slugs remain lookup-able.
expect(isValidSkillSlugShape("admin")).toBe(true);
});
it("is case-insensitive (normalizes before checking)", () => {
// Matches legacy read-path behaviour: search queries like "My-Skill"
// should still resolve to the slug row.
expect(isValidSkillSlugShape("A-B")).toBe(true);
});
it("returns false for malformed slugs", () => {
expect(isValidSkillSlugShape("a")).toBe(false);
expect(isValidSkillSlugShape("ab")).toBe(false);
expect(isValidSkillSlugShape("a--b")).toBe(false);
expect(isValidSkillSlugShape("-abc")).toBe(false);
expect(isValidSkillSlugShape("abc-")).toBe(false);
expect(isValidSkillSlugShape("a_b")).toBe(false);
expect(isValidSkillSlugShape("")).toBe(false);
expect(isValidSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(false);
});
});
describe("isReservedSkillSlug", () => {
it("identifies reserved slugs case-insensitively", () => {
expect(isReservedSkillSlug("admin")).toBe(true);
expect(isReservedSkillSlug(" ADMIN ")).toBe(true);
expect(isReservedSkillSlug("openclaw")).toBe(true);
});
it("returns false for non-reserved slugs", () => {
expect(isReservedSkillSlug("my-skill")).toBe(false);
expect(isReservedSkillSlug("")).toBe(false);
expect(isReservedSkillSlug(null)).toBe(false);
});
});
describe("isSearchableSkillSlugShape", () => {
it("accepts slugs shorter than the write-path minimum (legacy rows)", () => {
// Single- and two-character slugs predate the min-length floor but may
// still exist in the skills table. Search must surface them via the
// exact-slug fast path.
expect(isSearchableSkillSlugShape("a")).toBe(true);
expect(isSearchableSkillSlugShape("ab")).toBe(true);
expect(isSearchableSkillSlugShape("a1")).toBe(true);
});
it("accepts regular well-formed slugs", () => {
expect(isSearchableSkillSlugShape("abc")).toBe(true);
expect(isSearchableSkillSlugShape("my-skill-1")).toBe(true);
});
it("accepts reserved slugs (read path ignores the blocklist)", () => {
// Legacy data may still carry reserved slugs; they must remain
// searchable even though the write path would now reject them.
expect(isSearchableSkillSlugShape("admin")).toBe(true);
expect(isSearchableSkillSlugShape("u")).toBe(true);
});
it("is case-insensitive", () => {
expect(isSearchableSkillSlugShape("A-B")).toBe(true);
expect(isSearchableSkillSlugShape(" My-Skill ")).toBe(true);
});
it("still rejects malformed shapes", () => {
expect(isSearchableSkillSlugShape("")).toBe(false);
expect(isSearchableSkillSlugShape(" ")).toBe(false);
expect(isSearchableSkillSlugShape("-abc")).toBe(false);
expect(isSearchableSkillSlugShape("abc-")).toBe(false);
expect(isSearchableSkillSlugShape("a--b")).toBe(false);
expect(isSearchableSkillSlugShape("a_b")).toBe(false);
expect(isSearchableSkillSlugShape("a b")).toBe(false);
expect(isSearchableSkillSlugShape("-")).toBe(false);
});
it("accepts slugs longer than the write-path upper bound (legacy rows)", () => {
// Legacy rows predate MAX_SLUG_LENGTH and may exceed 48 chars. The read
// path must still resolve them via the by_slug fast path; otherwise
// searchSkills falls back to scanning only the most recent digests and
// can miss older records entirely.
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength))).toBe(true);
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(true);
expect(isSearchableSkillSlugShape("a".repeat(200))).toBe(true);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { ConvexError } from "convex/values";
// Slug shape rules:
// - Lowercase letters, digits, and single hyphens only.
// - Must start and end with a letter or digit.
// - No consecutive hyphens ("--", "---", ...).
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
//
// The pattern enforces first/last char class and forbids consecutive hyphens
// via a negative lookahead. Length bounds are checked separately so we can
// emit precise error messages.
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
const MIN_SLUG_LENGTH = 3;
const MAX_SLUG_LENGTH = 48;
// Reserved slugs. These are blocked because they would:
// 1. Clash semantically with top-level routes under src/routes/*.
// 2. Allow brand/role impersonation (e.g. "official", "clawhub").
// 3. Lock future route expansion (e.g. "api", "auth", "oauth").
//
// Keep this list in sync with:
// - src/routes/*.tsx top-level segments
// - brand names shipped in README.md
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
// Current top-level route segments under src/routes/.
"about",
"admin",
"cli",
"dashboard",
"import",
"management",
"orgs",
"packages",
"plugins",
"publish",
"publish-plugin",
"publish-skill",
"search",
"settings",
"skills",
"souls",
"stars",
"u",
"upload",
"users",
// Reserved for likely future additions.
"api",
"auth",
"oauth",
"callback",
"login",
"logout",
"signin",
"signout",
"signup",
"register",
"docs",
"doc",
"help",
"support",
"status",
"health",
"blog",
"news",
"pricing",
"terms",
"privacy",
"legal",
"contact",
"home",
"explore",
// Brand and project names.
"openclaw",
"clawhub",
"clawd",
"clawdbot",
"onlycrabs",
"soulhub",
// Generic identity / role words.
"me",
"self",
"system",
"root",
"owner",
"official",
"staff",
"team",
"mod",
"moderator",
// Reserved CRUD/action words that would make URLs ambiguous.
"new",
"edit",
"delete",
"create",
"update",
"remove",
"public",
"private",
"internal",
// Literals that would be confusing in URLs.
"null",
"undefined",
"true",
"false",
]);
export interface ValidateSlugOptions {
/**
* Bypass the reserved-word blocklist.
* Intended for admin migrations / internal seeding only.
*/
allowReserved?: boolean;
}
export const SKILL_SLUG_CONSTRAINTS = {
minLength: MIN_SLUG_LENGTH,
maxLength: MAX_SLUG_LENGTH,
pattern: SLUG_PATTERN,
reserved: RESERVED_SKILL_SLUGS,
} as const;
/**
* Lowercase and trim a slug. Does not throw.
*
* Safe to call on any read-path input (query by slug, redirect lookup, ...)
* without rejecting legacy data.
*/
export function normalizeSkillSlug(raw: string | undefined | null): string {
return (raw ?? "").trim().toLowerCase();
}
/**
* Variant that returns null when the input normalizes to an empty string.
* Useful at read-path call sites that want to short-circuit lookup.
*/
export function normalizeSkillSlugOrNull(raw: string | undefined | null): string | null {
const normalized = normalizeSkillSlug(raw);
return normalized.length ? normalized : null;
}
/**
* Check whether a string already matches the full slug shape rules.
* Returns true only when the value is a plausible slug (length, pattern).
*
* Note: this intentionally does NOT consult the reserved-word blocklist
* because legacy rows may still carry reserved slugs and we want to
* keep them readable. It DOES enforce the current min-length floor
* (MIN_SLUG_LENGTH) and is therefore only appropriate for call sites
* that treat a value as a "newly-shaped" slug. For read-only lookups
* (search, redirect) that must stay discoverable for pre-existing
* short slugs, use isSearchableSkillSlugShape instead.
*/
export function isValidSkillSlugShape(value: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(value);
if (normalized.length < MIN_SLUG_LENGTH || normalized.length > MAX_SLUG_LENGTH) {
return false;
}
return SLUG_PATTERN.test(normalized);
}
/**
* Lenient shape check used by read paths (search exact-slug optimization,
* redirect lookups, etc.).
*
* Unlike isValidSkillSlugShape, this predicate intentionally omits:
* - the min-length floor (legacy rows with 1- or 2-char slugs must stay
* retrievable via the by_slug fast path),
* - the max-length cap (rows persisted before MAX_SLUG_LENGTH was
* introduced may exceed 48 chars and must remain lookup-able; the
* indexed point lookup for a missing key is cheap, and upstream
* request-body limits bound the practical query length),
* - the reserved-word blocklist (grandfathered data must stay readable).
*
* Write paths MUST continue to use assertValidSkillSlug, which enforces
* the full validation surface (length floor + length cap + pattern +
* reserved blocklist).
*/
export function isSearchableSkillSlugShape(value: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(value);
if (normalized.length === 0) {
return false;
}
// Single-character legacy slug: a bare [a-z0-9] is searchable. The full
// SLUG_PATTERN requires >=2 chars (separate first/last classes), so we
// handle the single-char case explicitly before delegating to it.
if (normalized.length === 1) {
return /^[a-z0-9]$/.test(normalized);
}
return SLUG_PATTERN.test(normalized);
}
/**
* Returns a normalized slug or throws ConvexError describing the first
* violation encountered. Use this on every write path (publish/rename).
*/
export function assertValidSkillSlug(
rawSlug: string | undefined | null,
options: ValidateSlugOptions = {},
): string {
const normalized = normalizeSkillSlug(rawSlug);
if (!normalized) {
throw new ConvexError("Slug is required.");
}
if (normalized.length < MIN_SLUG_LENGTH) {
throw new ConvexError(`Slug must be at least ${MIN_SLUG_LENGTH} characters.`);
}
if (normalized.length > MAX_SLUG_LENGTH) {
throw new ConvexError(`Slug must be at most ${MAX_SLUG_LENGTH} characters.`);
}
if (!SLUG_PATTERN.test(normalized)) {
throw new ConvexError(
"Slug must start and end with a letter or digit, contain only lowercase letters, " +
"digits, and single hyphens, and not contain consecutive hyphens.",
);
}
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
}
return normalized;
}
/**
* Convenience predicate: is the slug on the reserved blocklist?
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
*/
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(slug);
return RESERVED_SKILL_SLUGS.has(normalized);
}
+20 -6
View File
@@ -1,5 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -16,6 +16,7 @@ import {
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSoulChangelogForPublish } from "./soulChangelog";
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
@@ -84,18 +85,31 @@ export async function publishSoulVersionForUser(
args: PublishVersionArgs,
): Promise<PublishResult> {
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
// Normalize first so we can look up the existing soul before deciding how
// strictly to validate. Owners of grandfathered slugs (reserved, <3 chars,
// or >48 chars) must still be able to publish new versions; the strict
// write-path rules only apply when creating a brand-new soul.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
await requireGitHubAccountAge(ctx, userId);
// Resolve existing soul before enforcing slug rules so grandfathered rows
// are not blocked. Full validation is only applied on the create path.
const existingSoul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
slug: normalizedSlug,
})) as Doc<"souls"> | null;
if (!existingSoul) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
+87 -2
View File
@@ -123,6 +123,7 @@ const insertReleaseInternalHandler = (
capabilities?: unknown;
verification?: unknown;
staticScan?: unknown;
allowExistingRelease?: boolean;
extractedPackageJson?: unknown;
extractedPluginManifest?: unknown;
normalizedBundleManifest?: unknown;
@@ -577,16 +578,40 @@ function makeInsertReleaseCtx(
}
if (table === "packageReleases") {
return {
withIndex: vi.fn((indexName: string) => {
withIndex: vi.fn(
(
indexName: string,
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
if (indexName === "by_package") {
return {
collect: vi.fn().mockResolvedValue(priorReleases),
};
}
if (indexName === "by_package_version") {
const filters = new Map<string, unknown>();
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return {
unique: vi.fn().mockResolvedValue(
priorReleases.find(
(release) =>
release.packageId === filters.get("packageId") &&
release.version === filters.get("version"),
) ?? null,
),
};
}
return {
unique: vi.fn().mockResolvedValue(null),
};
}),
},
),
};
}
throw new Error(`Unexpected table ${table}`);
@@ -2011,6 +2036,66 @@ describe("packages public queries", () => {
});
});
it("rejects duplicate package versions by default", async () => {
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
makeReleaseDoc({
_id: "packageReleases:existing",
version: "1.0.0",
integritySha256: "abc123",
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
}),
).rejects.toThrow("Version 1.0.0 already exists");
});
it("treats matching workflow duplicate package releases as idempotent", async () => {
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
makeReleaseDoc({
_id: "packageReleases:existing",
version: "1.0.0",
integritySha256: "abc123",
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
allowExistingRelease: true,
}),
).resolves.toMatchObject({
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:existing",
});
expect(ctx.insert).not.toHaveBeenCalled();
expect(ctx.patch).not.toHaveBeenCalled();
});
it("adds a latest tag when an untagged promoted release becomes the package latest", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
+18 -1
View File
@@ -2005,6 +2005,9 @@ async function publishPackageImpl(
staticScan,
files,
integritySha256,
allowExistingRelease:
auth.kind === "github-actions" ||
(auth.kind === "user" && manualOverrideReason?.startsWith("GitHub Actions ")),
extractedPackageJson: packageJson,
extractedPluginManifest:
family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
@@ -2164,6 +2167,7 @@ export const insertReleaseInternal = internalMutation({
capabilities: v.optional(v.any()),
verification: v.optional(v.any()),
staticScan: v.optional(v.any()),
allowExistingRelease: v.optional(v.boolean()),
files: v.array(
v.object({
path: v.string(),
@@ -2285,7 +2289,20 @@ export const insertReleaseInternal = internalMutation({
q.eq("packageId", existing._id).eq("version", args.version),
)
.unique();
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
if (releaseExists) {
if (
args.allowExistingRelease &&
!releaseExists.softDeletedAt &&
releaseExists.integritySha256 === args.integritySha256
) {
return {
ok: true as const,
packageId: existing._id,
releaseId: releaseExists._id,
};
}
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
}
}
const priorReleases = existing
? await ctx.db
+12 -5
View File
@@ -12,6 +12,7 @@ import { matchesExactTokens, tokenize } from "./lib/searchText";
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
import { isSkillSuspicious } from "./lib/skillSafety";
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator";
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
@@ -123,7 +124,11 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
}
function isSlugLikeQuery(query: string) {
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
// Lenient shape check used by the read path: pattern + upper length cap only.
// The min-length floor and reserved-word blocklist are intentionally omitted
// so legacy rows (grandfathered short/reserved slugs) remain discoverable via
// the exact-slug fast path. Write paths still go through assertValidSkillSlug.
return isSearchableSkillSlugShape(query);
}
function matchesCapabilityTag(
@@ -183,8 +188,7 @@ export const searchSkills: ReturnType<typeof action> = action({
const results = await ctx.vectorSearch("skillEmbeddings", "by_embedding", {
vector,
limit: candidateLimit,
filter: (q) =>
q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
filter: (q) => q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
});
// Only hydrate embedding IDs we haven't seen yet (incremental).
@@ -371,8 +375,11 @@ export const lexicalFallbackSkills = internalQuery({
>();
// Exact slug match via the skills table (only one row, cheap).
const slugQuery = args.query.trim().toLowerCase();
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
// Use the lenient shape predicate so legacy rows with sub-min-length
// slugs stay discoverable; the caller in searchSkills already passes
// skipExactSlugLookup=true after running its own exact-slug lookup.
const slugQuery = normalizeSkillSlug(args.query);
if (!args.skipExactSlugLookup && isSearchableSkillSlugShape(slugQuery)) {
const exactSlugSkill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
+28 -14
View File
@@ -96,6 +96,7 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
import { readCanonicalStat } from "./lib/skillStats";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
@@ -551,7 +552,10 @@ function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
}
function normalizeSkillSlugKey(slug: string) {
return slug.trim().toLowerCase();
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able. Write paths must
// use `normalizeSkillSlugForWrite` / `assertValidSkillSlug` instead.
return normalizeSkillSlug(slug);
}
type SkillOwnerRef =
@@ -565,11 +569,9 @@ type SkillOwnerRef =
| undefined;
function normalizeSkillSlugForWrite(slug: string) {
const normalized = normalizeSkillSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
return normalized;
// Write-path: full validation (length, pattern, reserved words,
// no consecutive hyphens). See `lib/skillSlugValidator.ts`.
return assertValidSkillSlug(slug);
}
async function getSkillSlugAliasBySlug(ctx: Pick<QueryCtx | MutationCtx, "db">, slug: string) {
@@ -5789,13 +5791,11 @@ async function renameOwnedSkillByActor(
}
const now = Date.now();
const sourceSlug = sourceSlugArg.trim().toLowerCase();
const newSlug = newSlugArg.trim().toLowerCase();
const sourceSlug = normalizeSkillSlug(sourceSlugArg);
if (!sourceSlug) throw new ConvexError("Current slug required");
if (!newSlug) throw new ConvexError("New slug required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(newSlug)) {
throw new ConvexError("Invalid slug. Use lowercase letters, numbers, and hyphens only.");
}
// Full write-path validation for the new slug: length, pattern,
// reserved-word blocklist, no consecutive hyphens.
const newSlug = assertValidSkillSlug(newSlugArg);
const resolved = await resolveSkillBySlugOrAlias(ctx, sourceSlug);
const skill = resolved.skill;
@@ -6530,7 +6530,16 @@ export const insertVersion = internalMutation({
},
handler: async (ctx, args) => {
const userId = args.userId;
const slug = normalizeSkillSlugForWrite(args.slug);
// Lenient normalization first so we can look up an existing skill row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
// pre-validator shapes) must remain able to publish new versions; the
// strict reserved/length/pattern rules only apply when creating a brand
// new skill. The caller (publishVersionForUser) performs the same split,
// but the mutation re-validates defensively because it can be invoked on
// its own (e.g. tests, internal schedulers).
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const personalPublisher = await ensurePersonalPublisherForUser(ctx, user);
@@ -6548,9 +6557,14 @@ export const insertVersion = internalMutation({
let skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.unique();
// Only enforce the strict write-path rules when creating a new skill.
// For existing rows, keep the already-persisted (possibly grandfathered)
// slug as-is so legacy publishers are not locked out of version updates.
const slug = skill ? normalizedSlug : normalizeSkillSlugForWrite(args.slug);
if (!skill) {
const alias = await getSkillSlugAliasBySlug(ctx, slug);
if (alias) {
+21 -8
View File
@@ -7,6 +7,7 @@ import { assertModerator, requireUser, requireUserFromAction } from "./lib/acces
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
import { toPublicSoul, toPublicUser } from "./lib/public";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
import { generateSoulChangelogPreview } from "./lib/soulChangelog";
import { fetchText, type PublishResult, publishSoulVersionForUser } from "./lib/soulPublish";
@@ -70,15 +71,15 @@ function toPublicSoulVersion(
}
function normalizeSoulSlugKey(slug: string) {
return slug.trim().toLowerCase();
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able.
return normalizeSkillSlug(slug);
}
function normalizeSoulSlugForWrite(slug: string) {
const normalized = normalizeSoulSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
return normalized;
// Write-path: full validation (length, pattern, reserved words,
// no consecutive hyphens). Souls share the rules with skills.
return assertValidSkillSlug(slug);
}
export const getBySlug = query({
@@ -497,17 +498,29 @@ export const insertVersion = internalMutation({
},
handler: async (ctx, args) => {
const userId = args.userId;
const slug = normalizeSoulSlugForWrite(args.slug);
// Lenient normalization first: we must look up the existing soul row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
// pre-validator shapes) must remain able to publish new versions; the
// strict rules only apply when creating a brand new soul. The caller
// (publishSoulVersionForUser) performs the same split, but the mutation
// re-validates defensively because it can be invoked on its own.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const soulMatches = await ctx.db
.query("souls")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.order("desc")
.take(2);
let soul: Doc<"souls"> | null = soulMatches[0] ?? null;
// Only enforce the strict write-path rules when creating a new soul; for
// existing rows keep the already-persisted (possibly grandfathered) slug.
const slug = soul ? normalizedSlug : normalizeSoulSlugForWrite(args.slug);
if (soul && soul.ownerUserId !== userId) {
throw new ConvexError("Only the owner can publish soul updates");
}
+1 -2
View File
@@ -394,12 +394,11 @@ export const list = query({
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 40, 1, 100);
const result = await queryUsersForPublicList(ctx, {
limit,
search: args.search,
});
return {
items: result.items
+1
View File
@@ -15,6 +15,7 @@
"dev": "bun --bun vite dev --port 3000",
"docs:list": "bun scripts/docs-list.ts",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
"lint": "bun run lint:oxlint",
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawhub",
"version": "0.11.0",
"version": "0.12.0",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"homepage": "https://clawhub.ai",
"bugs": {
@@ -0,0 +1,555 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
export const RESCAN_GUIDANCE_LABEL = "r: rescan-guidance";
export const RESCAN_GUIDANCE_COMMENT_MARKER = "<!-- clawhub-rescan-guidance -->";
export const SUPPRESS_LABEL = "skip-rescan-guidance";
const DEFAULT_REPO = "openclaw/clawhub";
const DEFAULT_LIMIT = 100;
const APPLY_CONFIRM_ENV = "CLAWHUB_RESCAN_GUIDANCE_APPLY";
const explicitIntentRules = [
{
id: "explicit-rescan",
pattern:
/\b(?:re[-\s]?scan|rerun(?:ning)?\s+(?:the\s+)?(?:security\s+)?scan|re-run\s+(?:the\s+)?(?:security\s+)?scan|run\s+(?:the\s+)?(?:security\s+)?scan\s+again|scan\s+again)\b/i,
},
{
id: "re-evaluation",
pattern: /\b(?:re[-\s]?evaluat(?:e|ion)|reassess|re-assess|re[-\s]?review)\b/i,
},
{
id: "reclassification",
pattern:
/\b(?:re[-\s]?classif(?:y|ication)|remove\s+(?:the\s+)?suspicious\s+flag|clear\s+(?:the\s+)?suspicious\s+flag|mark\s+(?:it\s+)?(?:as\s+)?(?:clean|benign))\b/i,
},
{
id: "review-after-fix",
pattern:
/\b(?:security\s+flag\s+review|scan\s+flag\s+review|(?:request(?:ing)?|please)\s+(?:a\s+)?(?:manual\s+)?(?:review|security\s+review)\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version)|review\s+request\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version))\b/i,
},
{
id: "fixed-and-still-flagged",
pattern:
/\b(?:(?:after|despite)\s+(?:fixing|fixes|metadata\s+fixes|clarifying|removing)|fix(?:ed|es)?\s+.*\b(?:still|yet)\s+.*\b(?:flagged|suspicious))\b/i,
},
];
const moderationContextRules = [
{
id: "clawhub-asset",
pattern: /\b(?:skill|plugin|package|publisher|published|version|clawhub)\b/i,
},
{
id: "moderation-signal",
pattern:
/\b(?:suspicious|flagged\s+(?:as\s+)?suspicious|security\s+scan|scanner|virustotal|vt\b|openclaw\s+verdict|moderation|malicious|benign|clean)\b/i,
},
];
const negativeContextRules = [
{
id: "auth-login",
pattern: /\b(?:login|log\s+in|sign[-\s]?in|oauth|unauthorized|token|callback)\b/i,
},
{
id: "install-rate-limit",
pattern: /\b(?:install(?:ing)?|rate\s+limit|429|download|npx)\b/i,
},
{
id: "search-indexing",
pattern: /\b(?:search|indexed|indexing|explore|catalog|disappeared|hidden)\b/i,
},
];
export const rescanGuidanceComment = [
RESCAN_GUIDANCE_COMMENT_MARKER,
'Thanks for the report. Please use the "Rescan" button on the skill/plugin page while signed in as the owner.',
"",
"You can also request a fresh scan from the CLI:",
"- Skill: `clawhub skill rescan <slug>`",
"- Plugin/package: `clawhub package rescan <name>`",
"",
"If the content or metadata changed, publish the fixed version first, then request the rescan for the latest release. I'm closing this issue after posting this guidance. If you're still having trouble after rescanning, please reopen this issue with the ClawHub URL, version, and latest scan result.",
].join("\n");
function normalizeLabel(label) {
if (typeof label === "string") return label.trim().toLowerCase();
if (label && typeof label.name === "string") return label.name.trim().toLowerCase();
return "";
}
function issueLabels(issue) {
return Array.isArray(issue.labels) ? issue.labels.map(normalizeLabel).filter(Boolean) : [];
}
function issueState(issue) {
return String(issue.state ?? "")
.trim()
.toUpperCase();
}
function issueText(issue) {
return `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
}
function matchingRuleIds(rules, text) {
return rules.filter((rule) => rule.pattern.test(text)).map((rule) => rule.id);
}
function commentHash(body) {
return createHash("sha256").update(body).digest("hex");
}
export function classifyRescanRequest(issue) {
const labels = issueLabels(issue);
const state = issueState(issue);
const text = issueText(issue);
if (state && state !== "OPEN") {
return {
matched: false,
matchedRules: [],
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
actions: [],
};
}
if (issue.pull_request || issue.isPullRequest) {
return {
matched: false,
matchedRules: [],
reason: "Skipped because this is a pull request.",
actions: [],
};
}
if (labels.includes(SUPPRESS_LABEL)) {
return {
matched: false,
matchedRules: [],
reason: `Skipped because ${SUPPRESS_LABEL} is present.`,
actions: [],
};
}
if (labels.includes(RESCAN_GUIDANCE_LABEL)) {
return {
matched: false,
matchedRules: [],
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is already present.`,
actions: [],
};
}
if (/\b(?:false\s+duplicate|duplicate\s+flag|not\s+a\s+duplicate|duplicate\s+of)\b/i.test(text)) {
return {
matched: false,
matchedRules: [],
reason:
"Skipped because this looks like a duplicate-classification appeal, not a rescan request.",
actions: [],
};
}
const explicitMatches = matchingRuleIds(explicitIntentRules, text);
if (explicitMatches.length === 0) {
return {
matched: false,
matchedRules: [],
reason: "No explicit rescan, re-evaluation, review, or reclassification request found.",
actions: [],
};
}
const contextMatches = matchingRuleIds(moderationContextRules, text);
if (contextMatches.length < moderationContextRules.length) {
return {
matched: false,
matchedRules: explicitMatches,
reason: "Explicit request found, but it lacks ClawHub asset and moderation/scan context.",
actions: [],
};
}
const negativeMatches = matchingRuleIds(negativeContextRules, text);
const hasStrongModerationLanguage =
/\b(?:suspicious|flagged|virustotal|vt\b|malicious|benign|clean|security\s+scan|scanner|moderation)\b/i.test(
text,
);
if (negativeMatches.length > 0 && !hasStrongModerationLanguage) {
return {
matched: false,
matchedRules: [...explicitMatches, ...contextMatches],
reason: `Skipped because it looks like ${negativeMatches.join(", ")} support rather than a moderation rescan request.`,
actions: [],
};
}
const matchedRules = [...explicitMatches, ...contextMatches];
return {
matched: true,
matchedRules,
reason: `Explicit rescan guidance match: ${matchedRules.join(", ")}.`,
actions: planRescanGuidanceActions(),
};
}
export function planRescanGuidanceActions() {
return [
{
type: "add_label",
label: RESCAN_GUIDANCE_LABEL,
},
];
}
export function planCommentForLabeledIssue(issue) {
const labels = issueLabels(issue);
const state = issueState(issue);
if (state && state !== "OPEN") {
return {
matched: false,
matchedRules: [],
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
actions: [],
};
}
if (issue.pull_request || issue.isPullRequest) {
return {
matched: false,
matchedRules: [],
reason: "Skipped because this is a pull request.",
actions: [],
};
}
if (!labels.includes(RESCAN_GUIDANCE_LABEL)) {
return {
matched: false,
matchedRules: [],
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is not present.`,
actions: [],
};
}
return {
matched: true,
matchedRules: ["rescan-guidance-label"],
reason: `Matched because ${RESCAN_GUIDANCE_LABEL} is present.`,
actions: [
{
type: "comment",
body: rescanGuidanceComment,
bodySha256: commentHash(rescanGuidanceComment),
},
{
type: "close",
stateReason: "not_planned",
},
],
};
}
function parseArgs(argv) {
const args = {
repo: DEFAULT_REPO,
limit: DEFAULT_LIMIT,
issues: [],
dryRun: true,
json: true,
commentForLabeledIssue: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--repo") {
args.repo = requireValue(argv, (index += 1), "--repo");
} else if (arg === "--limit") {
args.limit = Number.parseInt(requireValue(argv, (index += 1), "--limit"), 10);
} else if (arg === "--issue" || arg === "--item") {
args.issues.push(Number.parseInt(requireValue(argv, (index += 1), arg), 10));
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg === "--apply") {
args.dryRun = false;
} else if (arg === "--comment-for-labeled-issue") {
args.commentForLabeledIssue = true;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!Number.isInteger(args.limit) || args.limit < 1) {
throw new Error("--limit must be a positive integer.");
}
if (args.issues.some((issue) => !Number.isInteger(issue) || issue < 1)) {
throw new Error("--issue values must be positive integers.");
}
if (args.commentForLabeledIssue && args.issues.length === 0) {
throw new Error("--comment-for-labeled-issue requires --issue.");
}
if (!args.dryRun && process.env[APPLY_CONFIRM_ENV] !== "1") {
throw new Error(`--apply requires ${APPLY_CONFIRM_ENV}=1.`);
}
return args;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
function ghJson(args) {
const stdout = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
return JSON.parse(stdout);
}
function gh(args, input) {
execFileSync("gh", args, {
encoding: "utf8",
input,
maxBuffer: 64 * 1024 * 1024,
stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
});
}
function ghOk(args) {
try {
gh(args);
return true;
} catch {
return false;
}
}
function labelApiName(label) {
return encodeURIComponent(label);
}
function ensureGuidanceLabel(repo) {
if (ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) return;
const created = ghOk([
"label",
"create",
RESCAN_GUIDANCE_LABEL,
"--repo",
repo,
"--color",
"bfdadc",
"--description",
"Rescan guidance has been posted for this ClawHub item",
]);
if (!created && !ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) {
throw new Error(`Could not create or find label: ${RESCAN_GUIDANCE_LABEL}`);
}
}
function normalizeGhIssue(issue) {
return {
number: issue.number,
title: issue.title ?? "",
body: issue.body ?? "",
state: issue.state ?? "",
url: issue.url ?? issue.html_url ?? "",
labels: issue.labels ?? [],
};
}
async function fetchIssues(options) {
if (options.issues.length > 0) {
return options.issues.map((issueNumber) =>
normalizeGhIssue(
ghJson([
"issue",
"view",
String(issueNumber),
"--repo",
options.repo,
"--json",
"number,title,body,state,url,labels",
]),
),
);
}
return ghJson([
"issue",
"list",
"--repo",
options.repo,
"--state",
"open",
"--limit",
String(options.limit),
"--json",
"number,title,body,state,url,labels",
]).map(normalizeGhIssue);
}
export function planIssue(issue) {
const classification = classifyRescanRequest(issue);
return {
number: issue.number,
title: issue.title,
url: issue.url,
matched: classification.matched,
matchedRules: classification.matchedRules,
reason: classification.reason,
actions: classification.actions,
};
}
function writeCommentPayload(plan) {
const commentAction = plan.actions.find((action) => action.type === "comment");
if (!commentAction) return null;
return JSON.stringify({ body: commentAction.body });
}
function hasExistingGuidanceComment(repo, number) {
const comments = ghJson([
"api",
`repos/${repo}/issues/${number}/comments?per_page=100`,
"--jq",
`[.[] | {body}]`,
]);
return comments.some((comment) =>
String(comment.body ?? "").includes(RESCAN_GUIDANCE_COMMENT_MARKER),
);
}
function applyPlan(plan, options) {
if (!plan.matched) return { number: plan.number, applied: false, reason: plan.reason };
if (plan.actions.some((action) => action.type === "add_label")) {
ensureGuidanceLabel(options.repo);
}
const existingGuidanceComment = hasExistingGuidanceComment(options.repo, plan.number);
const appliedActions = [];
for (const action of plan.actions) {
if (action.type === "add_label") {
gh([
"api",
`repos/${options.repo}/issues/${plan.number}/labels`,
"--method",
"POST",
"--field",
`labels[]=${action.label}`,
]);
appliedActions.push(action.type);
} else if (action.type === "comment") {
if (existingGuidanceComment) continue;
gh(
[
"api",
`repos/${options.repo}/issues/${plan.number}/comments`,
"--method",
"POST",
"--input",
"-",
],
writeCommentPayload(plan),
);
appliedActions.push(action.type);
} else if (action.type === "close") {
gh(
["api", `repos/${options.repo}/issues/${plan.number}`, "--method", "PATCH", "--input", "-"],
JSON.stringify({ state: "closed", state_reason: action.stateReason ?? "not_planned" }),
);
appliedActions.push(action.type);
}
}
return {
number: plan.number,
applied: appliedActions.length > 0,
actions: appliedActions,
skippedComment: existingGuidanceComment,
};
}
function renderSummary(plans, options) {
const matches = plans.filter((plan) => plan.matched);
const lines = [
`ClawHub rescan auto-response ${options.dryRun ? "dry run" : "apply run"} for ${options.repo}`,
`Scanned ${plans.length} issue(s); matched ${matches.length}.`,
];
for (const plan of matches) {
lines.push(`- #${plan.number}: ${plan.title}`);
lines.push(` ${plan.url}`);
lines.push(` rules: ${plan.matchedRules.join(", ")}`);
}
return lines.join("\n");
}
function helpText() {
return [
"Usage: bun scripts/github/clawhub-rescan-auto-response.mjs [options]",
"",
"Options:",
" --repo <owner/repo> Repository to inspect. Default: openclaw/clawhub",
" --limit <n> Number of open issues to scan. Default: 100",
" --issue <n> Inspect one issue number. Repeatable.",
" --dry-run Preview only. Default.",
" --comment-for-labeled-issue",
` Post guidance only when ${RESCAN_GUIDANCE_LABEL} is already present.`,
` --apply Add the label and guidance comment. Requires ${APPLY_CONFIRM_ENV}=1.`,
" --help Show this help.",
].join("\n");
}
export async function runCli(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
console.log(helpText());
return;
}
const issues = await fetchIssues(options);
const plans = options.commentForLabeledIssue
? issues.map((issue) => {
const classification = planCommentForLabeledIssue(issue);
return {
number: issue.number,
title: issue.title,
url: issue.url,
matched: classification.matched,
matchedRules: classification.matchedRules,
reason: classification.reason,
actions: classification.actions,
};
})
: issues.map(planIssue);
const applyResults = options.dryRun ? [] : plans.map((plan) => applyPlan(plan, options));
console.error(renderSummary(plans, options));
console.log(
JSON.stringify(
{
repo: options.repo,
dryRun: options.dryRun,
scanned: plans.length,
matched: plans.filter((plan) => plan.matched).length,
applied: applyResults.filter((result) => result.applied).length,
applyResults,
plans,
},
null,
2,
),
);
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] === currentFile) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,191 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import {
RESCAN_GUIDANCE_COMMENT_MARKER,
RESCAN_GUIDANCE_LABEL,
SUPPRESS_LABEL,
classifyRescanRequest,
planCommentForLabeledIssue,
planIssue,
rescanGuidanceComment,
} from "./clawhub-rescan-auto-response.mjs";
const issue = (overrides) => ({
number: 1,
title: "placeholder",
body: "",
state: "OPEN",
url: "https://github.com/openclaw/clawhub/issues/1",
labels: [],
...overrides,
});
describe("clawhub rescan auto-response classifier", () => {
it.each([
[
1553,
"Re-scan jarviyin/clawpk v5.0.0 - remove suspicious flag",
"Please re-run the security scan on v5.0.0 and remove the suspicious flag. The package no longer contains any patterns that should trigger it.",
],
[
1834,
"feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
"The skill fixed credentials and Unicode control characters. Please re-run the security scan on v2.4.3.",
],
[
1808,
"Re-evaluation request: topview-skill (official Topview AI client) - medium-suspicious verdict triggered by emoji ZWJ false positive",
"Please re-scan at the current commit and reclassify as Benign. The suspicious scan findings have been fixed.",
],
[
1671,
'Request for Security Re-evaluation: "book-companion" skill marked as suspicious',
"I have proactively audited the skill and implemented compliance measures. Please review the updated documentation and clear the suspicious flag.",
],
])("matches explicit rescan/re-evaluation request #%s", (number, title, body) => {
const result = classifyRescanRequest(issue({ number, title, body }));
expect(result.matched).toBe(true);
expect(result.matchedRules.length).toBeGreaterThanOrEqual(3);
expect(result.actions).toEqual([{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }]);
expect(rescanGuidanceComment).toContain(RESCAN_GUIDANCE_COMMENT_MARKER);
});
it.each([
[
589,
"Rate limit exceeded when installing clawhub",
"npx clawhub@latest install sonoscli returns Rate limit exceeded. Is this not getting fixed?",
],
[
100,
"CLI: Auth fails due to redirect from clawhub.ai to www.clawhub.ai",
"The clawhub CLI fails to authenticate because a redirect loses the Authorization header.",
],
[
758,
"False positive: create-project skill flagged as suspicious by VirusTotal",
"The create-project skill has been flagged as suspicious. This appears to be the same class of false positive as other issues.",
],
[
256,
"False positive: clawarr-suite flagged as suspicious",
"Please review and unflag. All patterns are standard for a media server management tool.",
],
[
1514,
"False duplicate flag: claude-to-free is not a duplicate of model-migration",
"This skill is not a duplicate. Please remove the duplicate flag.",
],
])("does not match non-rescan issue #%s", (number, title, body) => {
const result = classifyRescanRequest(issue({ number, title, body }));
expect(result.matched).toBe(false);
expect(result.actions).toEqual([]);
});
it("skips closed issues", () => {
const result = classifyRescanRequest(
issue({
state: "CLOSED",
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
);
expect(result.matched).toBe(false);
expect(result.reason).toContain("closed");
});
it("skips pull requests", () => {
const result = classifyRescanRequest(
issue({
isPullRequest: true,
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
);
expect(result.matched).toBe(false);
expect(result.reason).toContain("pull request");
});
it("skips suppressed and already-handled issues", () => {
expect(
classifyRescanRequest(
issue({
labels: [{ name: SUPPRESS_LABEL }],
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
).reason,
).toContain(SUPPRESS_LABEL);
expect(
classifyRescanRequest(
issue({
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
).reason,
).toContain(RESCAN_GUIDANCE_LABEL);
});
it("plans dry-run rows for matched issues", () => {
const plan = planIssue(
issue({
number: 1834,
title: "feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
body: "This skill has fixed flagged metadata issues. Please re-run the security scan on v2.4.3.",
}),
);
expect(plan).toMatchObject({
number: 1834,
matched: true,
actions: [{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }],
});
});
it("plans comments only for issues already labeled for guidance", () => {
const plan = planCommentForLabeledIssue(
issue({
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
title: "False positive: example skill flagged as suspicious",
body: "Please re-run the security scan.",
}),
);
expect(plan).toMatchObject({
matched: true,
matchedRules: ["rescan-guidance-label"],
actions: [
{
type: "comment",
body: rescanGuidanceComment,
bodySha256: expect.any(String),
},
{
type: "close",
stateReason: "not_planned",
},
],
});
expect(rescanGuidanceComment).toContain("reopen this issue");
});
it("does not plan comments without the guidance label", () => {
const plan = planCommentForLabeledIssue(
issue({
title: "False positive: example skill flagged as suspicious",
body: "Please re-run the security scan.",
}),
);
expect(plan).toMatchObject({
matched: false,
actions: [],
});
});
});
+287 -133
View File
@@ -1,198 +1,352 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
import { beforeEach, describe, expect, it, vi } from "vitest";
type HeaderAuthStatus = {
isAuthenticated: boolean;
isLoading: boolean;
me: Record<string, unknown> | null;
};
const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
const { useUnifiedSearchMock } = vi.hoisted(() => ({
useUnifiedSearchMock: vi.fn(),
}));
const defaultUnifiedSearchResult = {
results: [],
skillResults: [
{
type: "skill",
ownerHandle: "local",
score: 10,
skill: {
_id: "skills:weather",
slug: "weather",
displayName: "Weather Skill",
ownerUserId: "users:local",
stats: { downloads: 1, stars: 2 },
createdAt: 1,
updatedAt: 2,
},
},
],
pluginResults: [
{
type: "plugin",
plugin: {
name: "weather-plugin",
displayName: "Weather Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
summary: "Plugin weather tools.",
ownerHandle: "local",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
capabilityTags: [],
executesCode: true,
verificationTier: null,
},
},
],
skillCount: 1,
pluginCount: 1,
isSearching: false,
};
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
className={props.className}
>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
const authStatusMock = vi.fn<() => HeaderAuthStatus>(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => authStatusMock(),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
}));
vi.mock("../lib/theme-transition", () => ({
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
}));
vi.mock("../lib/useAuthError", () => ({
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
}));
vi.mock("../lib/roles", () => ({
isModerator: () => false,
isModerator: () => false,
}));
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
gravatarUrl: vi.fn(),
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => useUnifiedSearchMock(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
import Header from "../components/Header";
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
beforeEach(() => {
authStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
});
siteModeMock.mockReturnValue("souls");
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
});
render(<Header />);
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
expect(screen.queryByText("Packages")).toBeNull();
});
render(<Header />);
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
expect(screen.queryByText("Packages")).toBeNull();
});
render(<Header />);
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
expect(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
render(<Header />);
fireEvent.click(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
);
expect(setModeMock).toHaveBeenCalledWith("dark");
expect(screen.getByRole("button", { name: /Toggle theme\. Current: system/i })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
expect(setModeMock).toHaveBeenCalledWith("dark");
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
render(<Header />);
it("shows grouped skills and plugins typeahead without users", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
render(<Header />);
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
const labels = Array.from(
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
)
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText("Skills")).toBeTruthy();
expect(screen.getByText("Weather Skill")).toBeTruthy();
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
expect(screen.getByText("Weather Plugin")).toBeTruthy();
expect(within(typeahead).queryByText("Users")).toBeNull();
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
});
render(<Header />);
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
useUnifiedSearchMock.mockReturnValue({
...defaultUnifiedSearchResult,
skillResults: [
{
...defaultUnifiedSearchResult.skillResults[0],
ownerHandle: null,
skill: {
...defaultUnifiedSearchResult.skillResults[0].skill,
ownerUserId: "users:opaque-id",
ownerPublisherId: "publishers:opaque-id",
},
},
],
pluginResults: [],
pluginCount: 0,
});
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
render(<Header />);
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
expect(navigateMock).not.toHaveBeenCalledWith(
expect.objectContaining({
to: "/publishers%3Aopaque-id/weather",
}),
);
});
it("shows a single no-results state without section footers", () => {
siteModeMock.mockReturnValue("skills");
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
isSearching: false,
});
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "zzzz" } });
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
expect(within(typeahead).queryByText("Skills")).toBeNull();
expect(within(typeahead).queryByText("Plugins")).toBeNull();
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
});
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const labels = Array.from(document.querySelectorAll(".mobile-nav-section .mobile-nav-link"))
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
it("keeps Stars out of signed-in header navigation", () => {
siteModeMock.mockReturnValue("skills");
authStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: {
displayName: "Patrick",
email: "patrick@example.com",
handle: "patrick",
image: null,
name: "Patrick",
},
});
render(<Header />);
expect(screen.queryByText("Stars")).toBeNull();
expect(screen.getAllByText("Dashboard").length).toBeGreaterThan(0);
expect(screen.getByText("Settings")).toBeTruthy();
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
});
+2 -3
View File
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
@@ -51,10 +50,10 @@ describe("search route", () => {
});
});
it("accepts the users type filter", () => {
it("ignores the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
type: undefined,
});
});
+18 -14
View File
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const navigateMock = vi.fn();
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
useNavigate: () => navigateMock,
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => ({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
userCount: 0,
isSearching: false,
}),
}));
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
@@ -64,7 +59,7 @@ describe("search route", () => {
const Component = route.__config.component as ComponentType;
const rendered = render(<Component />);
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
expect(input.value).toBe("first");
fireEvent.change(input, { target: { value: "draft" } });
@@ -74,7 +69,16 @@ describe("search route", () => {
rendered.rerender(<Component />);
expect(
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
).toBe("second");
});
it("does not render a public users search tab", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
});
});
+56
View File
@@ -0,0 +1,56 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.fn();
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
}),
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
async function loadRoute() {
return (await import("../routes/users/index")).Route as unknown as {
__config: {
component?: ComponentType;
validateSearch?: unknown;
};
};
}
describe("users route", () => {
beforeEach(() => {
vi.resetModules();
queryMock.mockReset();
queryMock.mockResolvedValue({ items: [], total: 0 });
});
it("does not expose public user search", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
await waitFor(() => expect(queryMock).toHaveBeenCalled());
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
expect(route.__config.validateSearch).toBeUndefined();
});
});
+347 -24
View File
@@ -1,19 +1,20 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
} from "../lib/nav-items";
import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
useUnifiedSearch,
type UnifiedPluginResult,
type UnifiedSkillResult,
} from "../lib/useUnifiedSearch";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -37,6 +38,24 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
type TypeaheadItem =
| {
kind: "skill";
key: string;
result: UnifiedSkillResult;
}
| {
kind: "plugin";
key: string;
result: UnifiedPluginResult;
}
| {
kind: "footer";
key: string;
section: "skills" | "plugins";
label: string;
};
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
@@ -62,10 +81,67 @@ export default function Header() {
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const searchWrapRef = useRef<HTMLDivElement | null>(null);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const trimmedNavSearchQuery = navSearchQuery.trim();
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
const {
skillResults,
skillCount,
pluginResults,
pluginCount,
isSearching: typeaheadSearching,
} = useUnifiedSearch(navSearchQuery, "all", {
debounceMs: 180,
enabled: showTypeahead,
limits: { skills: 4, plugins: 4 },
});
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
if (!showTypeahead) return [];
const items: TypeaheadItem[] = [];
for (const result of skillResults) {
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
}
if (skillCount > 0) {
items.push({
kind: "footer",
key: "footer-skills",
section: "skills",
label: `See skill results for "${trimmedNavSearchQuery}"`,
});
}
for (const result of pluginResults) {
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
}
if (pluginCount > 0) {
items.push({
kind: "footer",
key: "footer-plugins",
section: "plugins",
label: `See plugin results for "${trimmedNavSearchQuery}"`,
});
}
return items;
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
useEffect(() => {
setTypeaheadActiveIndex(0);
}, [trimmedNavSearchQuery]);
useEffect(() => {
if (!typeaheadOpen) return () => {};
const handlePointerDown = (event: PointerEvent) => {
if (searchWrapRef.current?.contains(event.target as Node)) return;
setTypeaheadOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [typeaheadOpen]);
const setThemeMode = (next: "system" | "light" | "dark") => {
applyTheme(next, theme);
@@ -89,9 +165,72 @@ export default function Header() {
: { q, type: undefined },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
if (item.kind === "skill") {
const resultOwnerHandle = item.result.ownerHandle?.trim();
if (!resultOwnerHandle) {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: "skills" },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
return;
}
void navigate({
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
});
} else if (item.kind === "plugin") {
void navigate({
to: "/plugins/$name",
params: { name: item.result.plugin.name },
});
} else {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: item.section },
});
}
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (isSoulMode) return;
if (event.key === "Escape") {
setTypeaheadOpen(false);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
if (!showTypeahead || typeaheadItems.length === 0) {
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
setTypeaheadOpen(true);
event.preventDefault();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setTypeaheadActiveIndex(
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
);
} else if (event.key === "Enter") {
const activeItem = typeaheadItems[typeaheadActiveIndex];
if (!activeItem) return;
event.preventDefault();
navigateToTypeaheadItem(activeItem);
}
};
return (
<header className="navbar">
<div className="navbar-inner">
@@ -176,17 +315,42 @@ export default function Header() {
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</form>
<div className="navbar-search-wrap" ref={searchWrapRef}>
<form
className="navbar-search"
onSubmit={handleNavSearch}
role="search"
aria-label="Site search"
>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => {
setNavSearchQuery(e.target.value);
setTypeaheadOpen(true);
}}
onFocus={() => setTypeaheadOpen(true)}
onKeyDown={handleSearchKeyDown}
aria-label="Search"
aria-expanded={showTypeahead}
aria-controls="navbar-search-typeahead"
autoComplete="off"
/>
</form>
{showTypeahead ? (
<SearchTypeahead
activeIndex={typeaheadActiveIndex}
items={typeaheadItems}
loading={typeaheadSearching}
onHoverItem={setTypeaheadActiveIndex}
onSelectItem={navigateToTypeaheadItem}
query={trimmedNavSearchQuery}
/>
) : null}
</div>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
@@ -248,9 +412,6 @@ export default function Header() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
@@ -287,7 +448,9 @@ export default function Header() {
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
>
@@ -305,19 +468,179 @@ export default function Header() {
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
/>
</form>
) : null}
</div>
</header>
);
}
function SearchTypeahead({
activeIndex,
items,
loading,
onHoverItem,
onSelectItem,
query,
}: {
activeIndex: number;
items: TypeaheadItem[];
loading: boolean;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
query: string;
}) {
const skillItems = items.filter((item) => item.kind === "skill");
const pluginItems = items.filter((item) => item.kind === "plugin");
const footerItems = items.filter((item) => item.kind === "footer");
const skillsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "skills",
);
const pluginsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "plugins",
);
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
return (
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Skills"
sectionItems={skillItems}
footer={skillsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Plugins"
sectionItems={pluginItems}
footer={pluginsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
{loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">Searching...</div>
) : null}
{!loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">
No skills or plugins found for "{query}"
</div>
) : null}
</div>
);
}
function TypeaheadSection({
activeIndex,
footer,
items,
label,
onHoverItem,
onSelectItem,
sectionItems,
}: {
activeIndex: number;
footer: TypeaheadItem | undefined;
items: TypeaheadItem[];
label: string;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
sectionItems: TypeaheadItem[];
}) {
if (sectionItems.length === 0 && !footer) return null;
return (
<div className="navbar-search-typeahead-section">
<div className="navbar-search-typeahead-heading">{label}</div>
{sectionItems.map((item) => (
<TypeaheadRow
key={item.key}
active={items[activeIndex]?.key === item.key}
item={item}
index={items.findIndex((candidate) => candidate.key === item.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
))}
{footer ? (
<TypeaheadRow
active={items[activeIndex]?.key === footer.key}
item={footer}
index={items.findIndex((candidate) => candidate.key === footer.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
) : null}
</div>
);
}
function TypeaheadRow({
active,
index,
item,
onHoverItem,
onSelectItem,
}: {
active: boolean;
index: number;
item: TypeaheadItem;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
}) {
const body = getTypeaheadRowBody(item);
return (
<button
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => onHoverItem(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelectItem(item)}
>
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
<span className="navbar-search-typeahead-copy">
<span className="navbar-search-typeahead-title">{body.title}</span>
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
</span>
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
</button>
);
}
function getTypeaheadRowBody(item: TypeaheadItem) {
if (item.kind === "skill") {
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
return {
icon: "S",
title: item.result.skill.displayName,
meta: `${owner} / ${item.result.skill.slug}`,
};
}
if (item.kind === "plugin") {
return {
icon: "P",
title: item.result.plugin.displayName,
meta: item.result.plugin.ownerHandle
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
: item.result.plugin.name,
};
}
return {
icon: null,
title: item.label,
meta: null,
};
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
+10 -2
View File
@@ -1,7 +1,7 @@
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
import { Calendar, Download, History, Package, Scale, Settings, Star, Upload } from "lucide-react";
import type { ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
@@ -171,7 +171,7 @@ export function SkillHeader({
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{isStaff || settingsHref ? (
{canManage || isStaff || settingsHref ? (
<div className="skill-title-actions">
{isStaff ? (
<Button asChild variant="outline" size="sm">
@@ -180,6 +180,14 @@ export function SkillHeader({
</Link>
</Button>
) : null}
{canManage ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload size={14} aria-hidden="true" />
New Version
</Link>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<a href={settingsHref}>
+46 -47
View File
@@ -1,9 +1,9 @@
import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
import type { PublicUser } from "./publicUser";
export type UnifiedSearchType = "all" | "skills" | "plugins";
export type UnifiedSkillResult = {
type: "skill";
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
plugin: PackageListItem;
};
export type UnifiedUserResult = {
type: "user";
user: PublicUser;
};
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
type UnifiedSearchOptions = {
debounceMs?: number;
enabled?: boolean;
limits?: {
skills?: number;
plugins?: number;
};
};
export function useUnifiedSearch(
query: string,
activeType: "all" | "skills" | "plugins" | "users",
activeType: UnifiedSearchType,
options: UnifiedSearchOptions = {},
) {
const searchSkills = useAction(api.search.searchSkills);
const [results, setResults] = useState<UnifiedResult[]>([]);
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
const [skillCount, setSkillCount] = useState(0);
const [pluginCount, setPluginCount] = useState(0);
const [userCount, setUserCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const requestRef = useRef(0);
const debounceMs = options.debounceMs ?? 300;
const enabled = options.enabled ?? true;
const skillLimit = options.limits?.skills ?? 25;
const pluginLimit = options.limits?.plugins ?? 25;
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
if (!enabled || !trimmed) {
requestRef.current += 1;
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return () => {};
}
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
const handle = window.setTimeout(() => {
void (async () => {
try {
const promises: [
Promise<unknown> | null,
Promise<{ items: PackageListItem[] }> | null,
Promise<{ items: PublicUser[] }> | null,
] = [null, null, null];
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
[null, null];
if (activeType === "all" || activeType === "skills") {
promises[0] = searchSkills({
query: trimmed,
limit: 25,
limit: skillLimit,
nonSuspiciousOnly: true,
});
}
if (activeType === "all" || activeType === "plugins") {
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
}
if (activeType === "all" || activeType === "users") {
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
}
const settled = await Promise.allSettled(
promises.map((p) => p ?? Promise.resolve(null)),
);
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
if (requestId !== requestRef.current) return;
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
const skillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
const nextSkillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{
skill: UnifiedSkillResult["skill"];
ownerHandle: string | null;
score: number;
}>) ?? []
).map((entry) => ({
type: "skill" as const,
skill: entry.skill,
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
score: entry.score,
}));
const pluginResults: UnifiedPluginResult[] = (
const nextPluginResults: UnifiedPluginResult[] = (
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
).map((item) => ({
type: "plugin" as const,
plugin: item,
}));
setSkillCount(skillResults.length);
setPluginCount(pluginResults.length);
const userResults: UnifiedUserResult[] = (
(usersRaw as { items: PublicUser[] })?.items ?? []
).map((user) => ({
type: "user" as const,
user,
}));
setUserCount(userResults.length);
setSkillCount(nextSkillResults.length);
setPluginCount(nextPluginResults.length);
setSkillResults(nextSkillResults);
setPluginResults(nextPluginResults);
const merged: UnifiedResult[] = [];
if (activeType === "all") {
merged.push(...skillResults, ...pluginResults, ...userResults);
merged.push(...nextSkillResults, ...nextPluginResults);
} else if (activeType === "skills") {
merged.push(...skillResults);
} else if (activeType === "plugins") {
merged.push(...pluginResults);
merged.push(...nextSkillResults);
} else {
merged.push(...userResults);
merged.push(...nextPluginResults);
}
setResults(merged);
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
console.error("Unified search failed:", error);
if (requestId === requestRef.current) {
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
}
} finally {
if (requestId === requestRef.current) {
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
}
}
})();
}, 300);
}, debounceMs);
return () => window.clearTimeout(handle);
}, [query, activeType, searchSkills]);
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
return { results, skillCount, pluginCount, userCount, isSearching };
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
}
+37
View File
@@ -1,6 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../../convex/_generated/api";
import { Settings } from "./settings";
const useQueryMock = vi.fn();
@@ -16,6 +18,15 @@ vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => useAuthActionsMock(),
}));
vi.mock("@tanstack/react-router", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
return {
...actual,
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
};
});
describe("Settings", () => {
beforeEach(() => {
useQueryMock.mockReset();
@@ -35,4 +46,30 @@ describe("Settings", () => {
expect(screen.getByText(/sign in to access settings\./i)).toBeTruthy();
expect(useQueryMock.mock.calls.some(([, args]) => args === "skip")).toBe(true);
});
it("links to starred skills from signed-in settings", () => {
useQueryMock.mockImplementation((query, args) => {
if (query === api.users.me) {
return {
_id: "user_123",
displayName: "Patrick",
name: "Patrick",
handle: "patrick",
email: "patrick@example.com",
image: null,
bio: null,
};
}
if (args === "skip") return undefined;
if (args && typeof args === "object" && "publisherHandle" in args) {
return undefined;
}
return [];
});
render(<Settings />);
expect(screen.getByRole("heading", { name: "Stars" })).toBeTruthy();
expect(screen.getByRole("link", { name: "View stars" }).getAttribute("href")).toBe("/stars");
});
});
+25 -336
View File
@@ -1,6 +1,5 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
@@ -12,6 +11,7 @@ import {
Star,
Users,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
@@ -28,13 +28,6 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
const SLOT_WORDS = [
"Equip", "Install", "Unleash", "Ship", "Build",
"Create", "Deploy", "Launch", "Hack", "Scale",
"Forge", "Craft", "Wield",
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@@ -104,262 +97,8 @@ function SkillsHome() {
// Build carousel cards from highlighted data
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
// ═══ SLOT MACHINE EASTER EGG ═══
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
const clickTimesRef = useRef<number[]>([]);
const [slotState, setSlotState] = useState<
| null
| { phase: "spinning" }
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
>(null);
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
const confettiRef = useRef<HTMLCanvasElement>(null);
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cooldownUntilRef = useRef<number>(0);
// Clean up timers/intervals if the component unmounts mid-spin
useEffect(() => {
return () => {
for (const t of slotTimersRef.current) clearTimeout(t);
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
};
}, []);
const triggerSlots = useCallback(() => {
// Clean up any previous timers
for (const t of slotTimersRef.current) clearTimeout(t);
slotTimersRef.current = [];
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
setSlotState({ phase: "spinning" });
setStoppedReels(new Set());
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
let r0: number, r1: number, r2: number;
const isJackpot = Math.random() < 1 / 25;
if (isJackpot) {
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
const isHack = Math.random() < 0.25;
if (isHack) {
r0 = HACK_INDEX;
} else {
// Pick any word except Hack
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
if (idx >= HACK_INDEX) idx++;
r0 = idx;
}
r1 = r0;
r2 = r0;
} else {
// Normal spin — re-roll if accidental triple match
do {
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
} while (r0 === r1 && r1 === r2);
}
const results: [number, number, number] = [r0, r1, r2];
const landed = new Set<number>();
// Animate fast offset cycling — only cycle reels that haven't landed
let frame = 0;
const spinInterval = setInterval(() => {
frame++;
setSlotReelOffsets((prev) => [
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
]);
}, 60);
spinIntervalRef.current = spinInterval;
// Stop reels sequentially with a satisfying stagger
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
const t = setTimeout(() => {
landed.add(reelIdx);
setStoppedReels((prev) => new Set(prev).add(reelIdx));
setSlotReelOffsets((prev) => {
const next = [...prev] as [number, number, number];
next[reelIdx] = results[reelIdx];
return next;
});
}, delay);
slotTimersRef.current.push(t);
};
stopReel(0, 1200);
stopReel(1, 1800);
const tFinal = setTimeout(() => {
clearInterval(spinInterval);
spinIntervalRef.current = null;
landed.add(2);
setStoppedReels(new Set([0, 1, 2]));
setSlotReelOffsets(results);
const won = r0 === r1 && r1 === r2;
const isHackJackpot = won && r0 === HACK_INDEX;
setSlotState({ phase: "stopped", results, won, isHackJackpot });
if (won) {
fireConfetti(isHackJackpot);
}
// Cooldown: 18s after win, 3s after loss
const displayTime = won ? 10000 : 2400;
const cooldownTime = won ? 18000 : 3000;
cooldownUntilRef.current = Date.now() + cooldownTime;
const tReset = setTimeout(() => {
setSlotState(null);
setStoppedReels(new Set());
}, displayTime);
slotTimersRef.current.push(tReset);
}, 2400);
slotTimersRef.current.push(tFinal);
}, []);
const handleLabelClick = useCallback(() => {
const now = Date.now();
// Respect cooldown period
if (now < cooldownUntilRef.current) return;
clickTimesRef.current.push(now);
// Keep only last 3 clicks
if (clickTimesRef.current.length > 3) {
clickTimesRef.current = clickTimesRef.current.slice(-3);
}
if (clickTimesRef.current.length === 3) {
const first = clickTimesRef.current[0];
const last = clickTimesRef.current[2];
if (last - first < 800 && !slotState) {
clickTimesRef.current = [];
triggerSlots();
}
}
}, [slotState, triggerSlots]);
const fireConfetti = (isHackJackpot: boolean) => {
const canvas = confettiRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = "block";
const STANDARD_COLORS = [
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
];
const OCEAN_COLORS = [
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
"#d4453a", "#ff6b6b",
];
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
type Particle = {
x: number; y: number; vx: number; vy: number;
w: number; h: number; color: string; rot: number; vr: number;
life: number; shape: "rect" | "bubble" | "claw";
};
const particles: Particle[] = [];
const count = isHackJackpot ? 200 : 150;
for (let i = 0; i < count; i++) {
const isBubble = isHackJackpot && Math.random() < 0.35;
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
particles.push({
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
y: canvas.height * 0.35,
vx: (Math.random() - 0.5) * 18,
vy: isHackJackpot
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
: -Math.random() * 16 - 4,
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
h: isBubble ? 0 : Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.3,
life: isHackJackpot ? 1.3 : 1,
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
});
}
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
context.beginPath();
context.moveTo(0, size * 0.5);
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
context.closePath();
context.fill();
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
for (const p of particles) {
if (p.life <= 0) continue;
alive = true;
p.x += p.vx;
p.y += p.vy;
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
p.vx *= 0.99;
p.rot += p.vr;
p.life -= isHackJackpot ? 0.005 : 0.008;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.fillStyle = p.color;
if (p.shape === "bubble") {
ctx.beginPath();
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
ctx.strokeStyle = p.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha *= 0.7;
ctx.stroke();
ctx.globalAlpha *= 0.15;
ctx.fill();
} else if (p.shape === "claw") {
drawClaw(ctx, p.w);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
if (alive) {
requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
const offset = slotReelOffsets[reelIdx];
const word = SLOT_WORDS[offset];
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
return (
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
<span className="home-v2-slot-word">{word}</span>
</span>
);
};
return (
<main className="home-v2-main">
{/* Confetti canvas for slot machine wins */}
<canvas
ref={confettiRef}
className="home-v2-confetti"
style={{ display: "none" }}
/>
{/* ═══ HERO ═══ */}
<section className="home-v2-hero">
<div className="home-v2-hero-bg">
@@ -370,81 +109,41 @@ function SkillsHome() {
<div className="home-v2-ring home-v2-ring-3" />
</div>
<div
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
onClick={handleLabelClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
>
BUILT BY THE COMMUNITY.
</div>
{slotState ? (
<h1 className={`home-v2-headline home-v2-headline-slots${
slotState.phase === "stopped" && slotState.won
? slotState.isHackJackpot
? " home-v2-headline-jackpot home-v2-headline-hack"
: " home-v2-headline-jackpot"
: ""
}`}>
{slotState.phase === "stopped" && slotState.isHackJackpot && (
<img
src="/clawd-mark.png"
alt=""
aria-hidden="true"
className="home-v2-hack-lobster"
/>
)}
<span className="home-v2-headline-inner">
{renderSlotReel(0)}
<span className="home-v2-sep" />
{renderSlotReel(1)}
<span className="home-v2-sep" />
{renderSlotReel(2)}
</span>
</h1>
) : (
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
</span>
</h1>
)}
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
</span>
</h1>
<div className="home-v2-search-container">
<form className="home-v2-search-bar" onSubmit={handleSearch}>
<Search className="home-v2-search-icon" size={20} />
<input
autoFocus
type="text"
placeholder="What are you looking for?"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<kbd>/</kbd>
<button type="submit" className="home-v2-search-go">
<span className="home-v2-search-go-label">Search</span>{" "}
<ArrowRight size={16} />
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
</button>
</form>
</div>
<div className="home-v2-suggestions">
<span className="home-v2-suggestions-label">Try</span>
<button
type="button"
className="home-v2-suggestion"
@@ -516,12 +215,10 @@ function SkillsHome() {
<div className="home-v2-c-footer">
<div className="home-v2-c-stats">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-c-install">
@@ -554,12 +251,10 @@ function SkillsHome() {
<div className="home-v2-c-footer">
<div className="home-v2-c-stats">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-c-install">
@@ -673,11 +368,7 @@ function SkillsHome() {
</div>
<div className="home-v2-trending-grid">
{popular.slice(0, 6).map((entry) => (
<Link
key={entry.skill._id}
to={skillLink(entry)}
className="home-v2-trend-card"
>
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
<div className="home-v2-trend-head">
<div className="home-v2-trend-title">
{entry.skill.displayName || entry.skill.slug}
@@ -692,12 +383,10 @@ function SkillsHome() {
<div className="home-v2-trend-bottom">
<div className="home-v2-trend-signals">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-trend-install">
+13 -43
View File
@@ -3,28 +3,24 @@ import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { PluginListItem } from "../components/PluginListItem";
import { SkillListItem } from "../components/SkillListItem";
import { UserListItem } from "../components/UserListItem";
import { Card } from "../components/ui/card";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import type { PublicSkill } from "../lib/publicUser";
import {
useUnifiedSearch,
type UnifiedSearchType,
type UnifiedPluginResult,
type UnifiedSkillResult,
type UnifiedUserResult,
} from "../lib/useUnifiedSearch";
type SearchState = {
q?: string;
type?: "all" | "skills" | "plugins" | "users";
type?: UnifiedSearchType;
};
export const Route = createFileRoute("/search")({
validateSearch: (search): SearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
type:
search.type === "skills" || search.type === "plugins" || search.type === "users"
? search.type
: undefined,
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
}),
component: UnifiedSearchPage,
});
@@ -39,7 +35,7 @@ function UnifiedSearchPage() {
setQuery(search.q ?? "");
}, [search.q]);
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
);
@@ -52,7 +48,7 @@ function UnifiedSearchPage() {
});
};
const setType = (type: "all" | "skills" | "plugins" | "users") => {
const setType = (type: UnifiedSearchType) => {
void navigate({
to: "/search",
search: { q: search.q, type: type === "all" ? undefined : type },
@@ -74,11 +70,11 @@ function UnifiedSearchPage() {
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar max-w-[560px] flex-1">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
type="text"
placeholder="Search skills, plugins, users..."
placeholder="Search skills and plugins..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
@@ -100,9 +96,7 @@ function UnifiedSearchPage() {
onClick={() => setType("skills")}
>
Skills
{skillCount > 0 ? (
<span className="search-tab-count">{skillCount}</span>
) : null}
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
@@ -110,17 +104,7 @@ function UnifiedSearchPage() {
onClick={() => setType("plugins")}
>
Plugins
{pluginCount > 0 ? (
<span className="search-tab-count">{pluginCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setType("users")}
>
Users
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
</button>
</div>
@@ -130,9 +114,7 @@ function UnifiedSearchPage() {
</Card>
) : !search.q ? (
<Card className="text-center p-10">
<p className="text-ink-soft">
Enter a search term to find skills, plugins, and users
</p>
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
@@ -143,10 +125,8 @@ function UnifiedSearchPage() {
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : item.type === "plugin" ? (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
) : (
<UserResultRow key={`user-${item.user._id}`} result={item} />
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
@@ -157,19 +137,9 @@ function UnifiedSearchPage() {
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
return (
<SkillListItem
skill={skill}
ownerHandle={result.ownerHandle}
/>
);
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
}
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
return <PluginListItem item={result.plugin} />;
}
function UserResultRow({ result }: { result: UnifiedUserResult }) {
const user = result.user as PublicUser;
return <UserListItem user={user} />;
}
+85 -31
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import {
Eye,
@@ -9,6 +9,7 @@ import {
Moon,
RotateCcw,
Settings2,
Star,
Sun,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -257,6 +258,21 @@ export function Settings() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star size={18} />
Stars
</CardTitle>
<CardDescription>Review skills you&apos;ve starred for quick access.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link to="/stars">View stars</Link>
</Button>
</CardContent>
</Card>
{/* Edit profile form */}
<Card>
<form className="flex flex-col gap-4" onSubmit={onSave}>
@@ -297,9 +313,7 @@ export function Settings() {
<Settings2 size={18} />
Customization
</CardTitle>
<CardDescription>
Personalize your ClawHub experience
</CardDescription>
<CardDescription>Personalize your ClawHub experience</CardDescription>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
@@ -316,7 +330,9 @@ export function Settings() {
<CardContent className="space-y-6">
{/* Theme Section */}
<div className="space-y-3">
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">
Theme
</Label>
<div className="flex flex-wrap gap-2">
<Button
variant={themeMode === "light" ? "primary" : "ghost"}
@@ -392,7 +408,7 @@ export function Settings() {
{/* Layout Section */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
@@ -400,7 +416,9 @@ export function Settings() {
</Label>
<Select
value={preferences.layoutDensity}
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
onValueChange={(value) =>
updatePreference("layoutDensity", value as LayoutDensity)
}
>
<SelectTrigger id="layout-density">
<SelectValue />
@@ -428,7 +446,9 @@ export function Settings() {
</Label>
<Select
value={preferences.listViewMode}
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
onValueChange={(value) =>
updatePreference("listViewMode", value as ListViewMode)
}
>
<SelectTrigger id="list-view">
<SelectValue />
@@ -453,7 +473,9 @@ export function Settings() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
<Label htmlFor="show-descriptions" className="text-sm">
Show descriptions
</Label>
<Switch
id="show-descriptions"
checked={preferences.showDescriptions}
@@ -461,7 +483,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
<Label htmlFor="show-stats" className="text-sm">
Show statistics
</Label>
<Switch
id="show-stats"
checked={preferences.showStats}
@@ -469,7 +493,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
<Label htmlFor="show-tags" className="text-sm">
Show tags
</Label>
<Switch
id="show-tags"
checked={preferences.showTags}
@@ -477,7 +503,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
<Label htmlFor="sticky-header" className="text-sm">
Sticky header
</Label>
<Switch
id="sticky-header"
checked={preferences.stickyHeader}
@@ -496,15 +524,20 @@ export function Settings() {
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Code &amp; Content
</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
<Label
htmlFor="code-font-size"
className="text-xs text-[color:var(--ink-soft)]"
>
Code font size
</Label>
<Select
value={preferences.codeFontSize}
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
onValueChange={(value) =>
updatePreference("codeFontSize", value as CodeFontSize)
}
>
<SelectTrigger id="code-font-size">
<SelectValue />
@@ -518,20 +551,23 @@ export function Settings() {
</div>
<div className="space-y-2">
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
<Label
htmlFor="animation-level"
className="text-xs text-[color:var(--ink-soft)]"
>
Animation level
</Label>
<Select
value={preferences.animationLevel}
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
onValueChange={(value) =>
updatePreference("animationLevel", value as AnimationLevel)
}
>
<SelectTrigger id="animation-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
Full
</SelectItem>
<SelectItem value="full">Full</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
@@ -541,7 +577,9 @@ export function Settings() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
<Label htmlFor="line-numbers" className="text-sm">
Line numbers in code
</Label>
<Switch
id="line-numbers"
checked={preferences.lineNumbers}
@@ -549,7 +587,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
<Label htmlFor="word-wrap" className="text-sm">
Word wrap in code
</Label>
<Switch
id="word-wrap"
checked={preferences.wordWrap}
@@ -567,11 +607,13 @@ export function Settings() {
<Eye size={14} className="text-[color:var(--accent)]" />
Accessibility
</Label>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
<Label htmlFor="reduced-motion" className="text-sm">
Reduced motion
</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
</div>
<Switch
@@ -582,8 +624,12 @@ export function Settings() {
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
<Label htmlFor="high-contrast" className="text-sm">
High contrast
</Label>
<p className="text-xs text-[color:var(--ink-soft)]">
Increase color contrast
</p>
</div>
<Switch
id="high-contrast"
@@ -601,16 +647,22 @@ export function Settings() {
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Experimental
</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they&apos;re released</p>
<Label htmlFor="experimental-features" className="text-sm">
Enable experimental features
</Label>
<p className="text-xs text-[color:var(--ink-soft)]">
Try new features before they&apos;re released
</p>
</div>
<Switch
id="experimental-features"
checked={preferences.experimentalFeatures}
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
onCheckedChange={(checked) =>
updatePreference("experimentalFeatures", checked)
}
/>
</div>
</div>
@@ -623,7 +675,9 @@ export function Settings() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
<p className="text-xs text-[color:var(--ink-soft)]">
Restore all settings to defaults
</p>
</div>
<Button
variant="ghost"
+13 -4
View File
@@ -64,14 +64,23 @@ function SoulsHoldingPage() {
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads",
dir: "desc",
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
<Link to="/users">Browse Users</Link>
</Button>
</div>
</section>
+5 -38
View File
@@ -1,38 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { UserListItem } from "../../components/UserListItem";
import { Card } from "../../components/ui/card";
import { UserListItem } from "../../components/UserListItem";
import { convexHttp } from "../../convex/client";
import type { PublicUser } from "../../lib/publicUser";
type UserSearchState = {
q?: string;
};
type UsersLoaderResult = { items: PublicUser[]; total: number };
export const Route = createFileRoute("/users/")({
validateSearch: (search): UserSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
}),
component: UsersIndex,
});
function UsersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [query, setQuery] = useState(search.q ?? "");
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
const [loading, setLoading] = useState(true);
const fetchUsers = useCallback(async (q?: string) => {
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const data = await convexHttp.query(api.users.listPublic, {
limit: 48,
search: q,
});
setResult(data as UsersLoaderResult);
} finally {
@@ -41,9 +29,8 @@ function UsersIndex() {
}, []);
useEffect(() => {
setQuery(search.q ?? "");
void fetchUsers(search.q);
}, [search.q, fetchUsers]);
void fetchUsers();
}, [fetchUsers]);
const users = result?.items ?? [];
@@ -57,25 +44,6 @@ function UsersIndex() {
) : null}
</h1>
</div>
<form
className="browse-page-search"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: {
q: query.trim() || undefined,
},
});
}}
>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search users..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className="browse-results">
<div className="browse-results-toolbar">
@@ -90,8 +58,7 @@ function UsersIndex() {
</Card>
) : users.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No users found</p>
<p className="empty-state-body">Try a different handle or name.</p>
<p className="empty-state-title">No users yet</p>
</div>
) : (
<div className="results-list">
+97 -197
View File
@@ -775,6 +775,11 @@ code {
box-shadow 0.15s ease;
}
.navbar-search-wrap {
position: relative;
min-width: 0;
}
.navbar-search:focus-within {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px var(--input-focus-ring);
@@ -807,6 +812,98 @@ code {
opacity: 0.7;
}
.navbar-search-typeahead {
position: absolute;
z-index: 50;
top: calc(100% + 8px);
left: 0;
right: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
border-top: 1px solid var(--line);
}
.navbar-search-typeahead-heading {
padding: 8px 12px;
background: var(--surface-muted);
color: var(--ink);
font-size: var(--fs-sm);
font-weight: 700;
}
.navbar-search-typeahead-row {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 8px 12px;
border-top: 1px solid var(--line);
color: var(--ink);
cursor: pointer;
}
.navbar-search-typeahead-row:hover,
.navbar-search-typeahead-row.is-active {
background: var(--surface-muted);
}
.navbar-search-typeahead-row.is-footer {
color: var(--ink-soft);
}
.navbar-search-typeahead-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-size: var(--fs-xs);
font-weight: 700;
flex: 0 0 auto;
}
.navbar-search-typeahead-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.navbar-search-typeahead-title,
.navbar-search-typeahead-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-search-typeahead-title {
font-size: var(--fs-sm);
font-weight: 600;
}
.navbar-search-typeahead-meta {
color: var(--ink-soft);
font-size: var(--fs-xs);
}
.navbar-search-typeahead-status {
padding: 12px;
color: var(--ink-soft);
font-size: var(--fs-sm);
}
.navbar-search-home {
justify-content: space-between;
cursor: pointer;
@@ -8510,21 +8607,6 @@ code {
}
/* Headline */
.home-v2-hero-label {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--hv2-text-tertiary);
margin-bottom: 20px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition:
color 0.2s,
text-shadow 0.3s;
}
.home-v2-headline {
font-family: "Inter", sans-serif;
font-weight: 700;
@@ -8613,159 +8695,6 @@ code {
}
}
/* ═══ SLOT MACHINE EASTER EGG ═══ */
.home-v2-hero-label:hover {
color: var(--hv2-accent);
}
.home-v2-hero-label-active {
color: var(--hv2-accent) !important;
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
}
@keyframes home-v2-labelPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.home-v2-headline-slots {
min-height: 1.15em;
position: relative;
}
.home-v2-slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 180px;
text-align: center;
font-family: "JetBrains Mono", monospace;
font-weight: 800;
color: var(--hv2-accent);
position: relative;
}
.home-v2-slot-reel.spinning .home-v2-slot-word {
animation: home-v2-slotBlur 0.12s steps(1) infinite;
}
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes home-v2-slotBlur {
0% {
filter: blur(0px);
opacity: 1;
}
50% {
filter: blur(1px);
opacity: 0.7;
}
100% {
filter: blur(0px);
opacity: 1;
}
}
@keyframes home-v2-slotLand {
0% {
transform: translateY(-8px) scale(1.1);
opacity: 0.5;
}
60% {
transform: translateY(2px) scale(0.98);
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.home-v2-headline-jackpot {
animation: home-v2-jackpot 0.5s ease-out;
}
@keyframes home-v2-jackpot {
0% {
transform: scale(1);
}
30% {
transform: scale(1.08);
}
60% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
.home-v2-headline-jackpot .home-v2-slot-word {
color: #ffd93d !important;
text-shadow:
0 0 20px rgba(255, 217, 61, 0.6),
0 0 40px rgba(255, 217, 61, 0.3);
}
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
.home-v2-headline-hack .home-v2-slot-word {
color: #22d3ee !important;
text-shadow:
0 0 24px rgba(34, 211, 238, 0.6),
0 0 48px rgba(6, 182, 212, 0.3),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
.home-v2-headline-hack .home-v2-sep {
border-color: #22d3ee;
opacity: 0.8;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
}
.home-v2-hack-lobster {
position: absolute;
top: 50%;
left: 50%;
width: 280px;
height: 280px;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
z-index: -1;
}
@keyframes home-v2-lobsterReveal {
0% {
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
opacity: 0;
}
50% {
opacity: 0.18;
}
100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 0.12;
}
}
.home-v2-confetti {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.home-v2-sub {
color: var(--hv2-text-tertiary);
font-size: 16px;
line-height: 1.5;
margin-bottom: 36px;
max-width: 580px;
font-weight: 400;
}
.home-v2-sub-clear {
color: var(--hv2-text-secondary);
max-width: 820px;
margin-bottom: 18px;
}
.home-v2-motto {
display: flex;
flex-direction: column;
@@ -8830,16 +8759,6 @@ code {
.home-v2-search-bar input::placeholder {
color: var(--hv2-text-tertiary);
}
.home-v2-search-bar kbd {
font-family: "Inter", sans-serif;
font-size: 11px;
background: transparent;
border: 1px solid var(--hv2-border);
border-radius: 5px;
padding: 2px 7px;
color: var(--hv2-text-tertiary);
flex-shrink: 0;
}
.home-v2-search-go {
background: var(--hv2-accent-fill);
color: var(--hv2-accent);
@@ -8877,12 +8796,6 @@ code {
margin-top: 16px;
flex-wrap: wrap;
}
.home-v2-suggestions-label {
color: var(--hv2-text-secondary);
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.home-v2-suggestion {
display: flex;
align-items: center;
@@ -8918,11 +8831,6 @@ code {
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
@@ -9714,11 +9622,6 @@ code {
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
color: #9c8b7a;
}
[data-theme-resolved="light"] .home-v2-search-bar kbd {
border-color: rgba(170, 125, 80, 0.2);
color: #9c8b7a;
}
/* Light — search button */
[data-theme-resolved="light"] .home-v2-search-go {
background: rgba(196, 58, 47, 0.06);
@@ -9901,9 +9804,6 @@ code {
.home-v2-search-go {
border-radius: 8px;
}
.home-v2-search-bar kbd {
border-radius: 8px;
}
.home-v2-c-icon {
border-radius: 8px;
}