Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b7f17d5
fix(install): surface Node shell-reload hint adjacent to install line…
cjagwani Apr 22, 2026
9ed9451
refactor(cli): tighten agent manifest typing (#2143)
cv Apr 22, 2026
7957889
test(e2e): add diagnostics, debug tarball, and credential E2E tests (…
TruongNguyenG Apr 22, 2026
458fe7a
fix(e2e): switch TC-NET-03/04 to non-base-policy endpoints (#2275)
TruongNguyenG Apr 22, 2026
59d3115
fix(install): invoke install-openshell.sh from install_nemoclaw (#2279)
laitingsheng Apr 22, 2026
3edf6b3
fix(rebuild): forward stored --from Dockerfile path to onboard on reb…
ericksoa Apr 22, 2026
752bfb3
fix(sandbox): add WebSocket CONNECT tunnel preload for Discord gatewa…
ericksoa Apr 22, 2026
6ffbec1
refactor(sandbox): extract shared entrypoint library to fix Hermes vu…
jyaunches Apr 23, 2026
e9a900b
fix(onboard): warn that dashboard URL is printed only once (#2278)
paritoshd-nv Apr 23, 2026
5ee32b9
fix: offer Ollama install fallback when cloud API is unavailable (#380)
futhgar Apr 23, 2026
1b45c2a
test(e2e): skip cleanly under VPN, cover Discord token rotation (#2257)
hunglp6d Apr 23, 2026
fafbaec
chore(install): bump OpenShell version to 0.0.32 (#2307)
prekshivyas Apr 23, 2026
5fec344
feat(skills): add title tag normalization maintainer skill (#2292)
cv Apr 23, 2026
72056a3
docs: update commands reference for 0.0.23 release (#2312)
miyoungc Apr 23, 2026
c538022
fix(onboard): classify expired API key as credential error (#2132)
latenighthackathon Apr 23, 2026
21c537c
fix(jetson): use python3 to patch daemon.json instead of sed (#1913)
BenediktSchackenberg Apr 23, 2026
6af4dad
fix(sandbox): rewrite #2109 proxy fix as http.request wrapper
lcsmontiel Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .agents/skills/nemoclaw-maintainer-normalize-title-tags/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
name: nemoclaw-maintainer-normalize-title-tags
description: Normalizes GitHub issue and PR titles by removing any bracketed [NemoClaw] tag case-insensitively, even when the tag appears later in the title. Use when cleaning issue tags, bulk-renaming titles, or normalizing repo title hygiene.
user_invocable: true
---

<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# NemoClaw Maintainer — Normalize Title Tags

Preview and optionally apply bulk title cleanup for bracketed NemoClaw tags in GitHub issue and PR titles.

## Examples

- `[NemoClaw][All Platforms] local-inference policy preset missing Ollama ports` → `[All Platforms] local-inference policy preset missing Ollama ports`
- `[Bug] [Nemoclaw] [Slack] Slack configuration in Nemoclaw Onboard fails` → `[Bug] [Slack] Slack configuration in Nemoclaw Onboard fails`

## Prerequisites

- You must be in the NemoClaw git repository.
- The `gh` CLI must be authenticated with write access to `NVIDIA/NemoClaw`.
- Default behavior is a dry run. Do not apply changes until the user approves the preview.

## Workflow

Copy this checklist and track progress:

```text
Title tag cleanup progress:
- [ ] Step 1: Verify GitHub auth
- [ ] Step 2: Preview proposed title changes
- [ ] Step 3: Confirm scope
- [ ] Step 4: Apply changes
- [ ] Step 5: Verify no matching tags remain in scope
```

## Step 1: Verify GitHub Auth

```bash
gh auth status
```

## Step 2: Preview Proposed Changes

```bash
node --experimental-strip-types --no-warnings \
.agents/skills/nemoclaw-maintainer-normalize-title-tags/scripts/normalize-title-tags.ts
```

The script matches bracket tags whose content is `nemoclaw`, case-insensitively, anywhere in the title.
It prints a dry-run summary by default. Review the proposed renames with the user before applying anything.

## Step 3: Confirm Scope

Ask the user which scope they want:

- **Default** — all open and closed issues and PRs in `NVIDIA/NemoClaw`
- **State filter** — optionally limit to `open` or `closed`
- **Repo override** — only when the user explicitly wants a different repository

## Step 4: Apply Changes

Apply to all items:

```bash
node --experimental-strip-types --no-warnings \
.agents/skills/nemoclaw-maintainer-normalize-title-tags/scripts/normalize-title-tags.ts \
--apply
```

Apply only to open items:

```bash
node --experimental-strip-types --no-warnings \
.agents/skills/nemoclaw-maintainer-normalize-title-tags/scripts/normalize-title-tags.ts \
--state open \
--apply
```

## Step 5: Verify

The script automatically re-runs the same search after `--apply` and exits non-zero if matching tags remain.

If verification fails, stop and show the remaining matches to the user instead of retrying blindly.

## Notes

- The script uses the GitHub Issues API, which covers both issues and pull requests.
- It removes only bracket tags whose content is `nemoclaw`, ignoring case. Plain-text mentions of `NemoClaw` are untouched.
- The default repository is `NVIDIA/NemoClaw`. Pass `--repo OWNER/REPO` only when the user explicitly wants a different repo.
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Preview or apply cleanup for bracketed NemoClaw tags in GitHub issue and PR titles.
*
* Removes any bracket tag whose content is `nemoclaw`, case-insensitively,
* anywhere in the title.
*
* Usage:
* node --experimental-strip-types --no-warnings \
* .agents/skills/nemoclaw-maintainer-normalize-title-tags/scripts/normalize-title-tags.ts \
* [--repo OWNER/REPO] [--state all|open|closed] [--apply]
*/

import { execFileSync } from "node:child_process";

type QueryState = "all" | "open" | "closed";
type ItemState = "open" | "closed";
type ItemType = "issue" | "pr";

interface GitHubIssueLike {
number: number;
title: string;
state: ItemState;
html_url: string;
pull_request?: unknown;
}

interface TitleCleanup {
matchedTags: string[];
newTitle: string;
}

interface Match {
number: number;
type: ItemType;
state: ItemState;
url: string;
matchedTags: string[];
oldTitle: string;
newTitle: string;
}

interface Options {
repo: string;
state: QueryState;
apply: boolean;
}

const BRACKET_TAG_REGEX = /\[[^\]]+\]/g;

function usage(): string {
return [
"Usage:",
" node --experimental-strip-types --no-warnings \\",
" .agents/skills/nemoclaw-maintainer-normalize-title-tags/scripts/normalize-title-tags.ts \\",
" [--repo OWNER/REPO] [--state all|open|closed] [--apply]",
"",
"Defaults:",
" --repo NVIDIA/NemoClaw",
" --state all",
" dry-run mode unless --apply is provided",
].join("\n");
}

function run(cmd: string, args: string[]): string {
return execFileSync(cmd, args, {
encoding: "utf-8",
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}

function ghJson(args: string[]): unknown {
return JSON.parse(run("gh", args));
}

function parseArgs(argv: string[]): Options {
const options: Options = {
repo: "NVIDIA/NemoClaw",
state: "all",
apply: false,
};

for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];

if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
}

if (arg === "--apply") {
options.apply = true;
continue;
}

if (arg === "--repo") {
const value = argv[i + 1];
if (!value || value.startsWith("--")) {
throw new Error("--repo requires OWNER/REPO");
}
options.repo = value;
i += 1;
continue;
}

if (arg === "--state") {
const value = argv[i + 1] as QueryState | undefined;
if (value !== "all" && value !== "open" && value !== "closed") {
throw new Error("--state must be one of: all, open, closed");
}
options.state = value;
i += 1;
continue;
}

throw new Error(`Unknown argument: ${arg}`);
}

return options;
}

function isNemoclawTag(tag: string): boolean {
return tag.slice(1, -1).trim().toLowerCase() === "nemoclaw";
}

function cleanupTitle(title: string): string {
return title.replace(/\s{2,}/g, " ").trim();
}

function stripNemoclawTags(title: string): TitleCleanup {
const matchedTags: string[] = [];

const withoutTags = title.replace(BRACKET_TAG_REGEX, (tag) => {
if (!isNemoclawTag(tag)) {
return tag;
}
matchedTags.push(tag);
return "";
});

return {
matchedTags,
newTitle: cleanupTitle(withoutTags),
};
}

function listItems(repo: string, state: QueryState): GitHubIssueLike[] {
const items: GitHubIssueLike[] = [];

for (let page = 1; ; page += 1) {
const response = ghJson([
"api",
`repos/${repo}/issues?state=${state}&per_page=100&page=${page}`,
]);

if (!Array.isArray(response) || response.length === 0) {
break;
}

for (const item of response) {
if (
item &&
typeof item === "object" &&
typeof item.number === "number" &&
typeof item.title === "string" &&
(item.state === "open" || item.state === "closed") &&
typeof item.html_url === "string"
) {
items.push(item as GitHubIssueLike);
}
}
}

return items;
}

function collectMatches(options: Options): Match[] {
const matches: Match[] = [];

for (const item of listItems(options.repo, options.state)) {
const cleaned = stripNemoclawTags(item.title);
if (cleaned.matchedTags.length === 0 || cleaned.newTitle === item.title) {
continue;
}

if (!cleaned.newTitle) {
console.error(`Skipping #${item.number}: cleanup would produce an empty title.`);
continue;
}

matches.push({
number: item.number,
type: item.pull_request ? "pr" : "issue",
state: item.state,
url: item.html_url,
matchedTags: cleaned.matchedTags,
oldTitle: item.title,
newTitle: cleaned.newTitle,
});
}

return matches.sort((a, b) => a.number - b.number);
}

function printSummary(options: Options, matches: Match[]): void {
const tagCounts = new Map<string, number>();
let issueCount = 0;
let prCount = 0;
let tagTotal = 0;

for (const match of matches) {
for (const tag of match.matchedTags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
tagTotal += 1;
}
if (match.type === "issue") {
issueCount += 1;
} else {
prCount += 1;
}
}

console.log(`Mode: ${options.apply ? "apply" : "dry-run"}`);
console.log(`Repo: ${options.repo}`);
console.log(`State: ${options.state}`);
console.log(`Title matches: ${matches.length} (${issueCount} issues, ${prCount} PRs)`);
console.log(`Tags to remove: ${tagTotal}`);

if (tagCounts.size > 0) {
console.log("Tag counts:");
for (const [tag, count] of tagCounts.entries()) {
console.log(` ${tag}: ${count}`);
}
}

if (matches.length === 0) {
console.log("No matching titles found.");
return;
}

console.log("");
for (const match of matches) {
console.log(`#${match.number} [${match.type}] [${match.state}] ${match.url}`);
console.log(` tags: ${match.matchedTags.join(", ")}`);
console.log(` old: ${match.oldTitle}`);
console.log(` new: ${match.newTitle}`);
}
}

function applyMatches(options: Options, matches: Match[]): void {
for (const match of matches) {
run("gh", [
"api",
"-X",
"PATCH",
`repos/${options.repo}/issues/${match.number}`,
"-f",
`title=${match.newTitle}`,
]);
console.log(`UPDATED #${match.number}: ${match.oldTitle} -> ${match.newTitle}`);
}
}

function main(): void {
try {
const options = parseArgs(process.argv.slice(2));
const matches = collectMatches(options);
printSummary(options, matches);

if (!options.apply || matches.length === 0) {
return;
}

console.log("");
applyMatches(options, matches);

console.log("\nVerifying...");
const remaining = collectMatches({ ...options, apply: false });
if (remaining.length > 0) {
console.error(`Verification failed: ${remaining.length} matching titles remain.`);
process.exit(1);
}

console.log("Verification passed: 0 matching titles remain.");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
console.error(usage());
process.exit(1);
}
}

main();
Loading
Loading