mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
fix(skills): deploy before pruning, block share on an unloadable config, drop hidden commands from the reference
- Legacy trees were pruned before the stub was written, so a refused or failed stub (a link, a read-only directory) left the agent with nothing to discover. They go only once the stub deployed for that agent. - The share gate failed open on any config error. Only a machine with no config (NotInitializedError) is served; a config that exists but cannot be loaded blocks with its own reason, `blockedBy: "config"`. - The KB template told agents to run `code-to-knowledge --update`, which does not exist; it names `teamai codebase --extract … --incremental`. - The generated command reference listed hidden hook plumbing (`track`, `contribute-check`, `todowrite-hint`, …). It renders what `--help` lists. - removeEmptyDirs swallowed every rmdir error, so a directory that stayed could be reported removed. Only "still holds something" is expected; any other failure is reported.
This commit is contained in:
@@ -89,8 +89,10 @@ path (measured here from a 77-character one).
|
||||
`skill get <name>` refuses, `skill get --all` leaves the skill out and says so
|
||||
on stderr, `skill path <name>` and `skill show <name>` refuse, and
|
||||
`skill list --json` reports `blockedBy: "recall"` with `path: null`. With no
|
||||
team config to consult — or one it cannot load — it fails open: a refusal the
|
||||
member cannot act on is worse than serving the workflow. The Stop-hook share
|
||||
config on the machine at all it fails open: a refusal a fresh install cannot act
|
||||
on is worse than serving the workflow. A config that exists but cannot be loaded
|
||||
blocks instead (`blockedBy: "config"`), since recall and the source are then
|
||||
unknown and the workflow would fail at `teamai contribute`. The Stop-hook share
|
||||
reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`),
|
||||
because it points at this command. The gate lives in one place:
|
||||
`resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a
|
||||
@@ -176,7 +178,7 @@ member's own skill that uses a legacy name, a root TeamAI never managed because
|
||||
directory. Checking the path alone would have deleted those. What is removed is
|
||||
still copied first to
|
||||
`~/.teamai/removed-skills/<run>/<base>/<tool>/<skill-root>/<skill>/`, so no
|
||||
removal is a one-way door. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the
|
||||
removal is a one-way door. The legacy trees go only after the stub deployed for that agent: pruning first and then failing to write the stub (a link, a read-only directory) would leave nothing to discover. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the
|
||||
stub goes to the shared one when a copy already lives there; the copy an earlier
|
||||
release left in the other root is retired by the same rule
|
||||
(`retireOtherCodexCopy`), so Codex never sees a stale `teamai` beside the current
|
||||
|
||||
@@ -158,7 +158,6 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `--token <key>` — API token for the HTTP endpoint (stored 0600, never committed)
|
||||
- `--force` — Overwrite an existing HTTP source config
|
||||
- `teamai source remove-http` — Remove the HTTP source and clean up its resources
|
||||
- `teamai source reconcile-plugins` — Run plugin reconcile worker (called internally by session_start hook)
|
||||
- `teamai source list` — List all configured sources
|
||||
- `teamai source browse <name>` — Browse public skills from a source
|
||||
|
||||
@@ -207,18 +206,6 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `teamai webhook test` — Send test event to webhook endpoints
|
||||
- `--url <url>` — Test specific endpoint URL
|
||||
|
||||
## track
|
||||
|
||||
- `teamai track [toolName] [toolInput]` — Track a tool usage event (called by PostToolUse hook)
|
||||
- `--stdin` — Read hook data from STDIN (Claude Code hook format)
|
||||
- `--tool <name>` — Tool identifier for usage attribution (e.g. claude, claude-internal)
|
||||
|
||||
## track-slash
|
||||
|
||||
- `teamai track-slash` — Track a slash command usage (called by UserPromptSubmit hook)
|
||||
- `--stdin` — Read hook data from STDIN
|
||||
- `--tool <name>` — Tool identifier for usage attribution (e.g. claude, claude-internal)
|
||||
|
||||
## stats
|
||||
|
||||
- `teamai stats` — Show local skill usage statistics
|
||||
@@ -244,12 +231,6 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `teamai dashboard` — Start the AI coding session dashboard (Web UI)
|
||||
- `-p, --port <port>` — Port number
|
||||
|
||||
## dashboard-report
|
||||
|
||||
- `teamai dashboard-report` — Report session state to dashboard (called by hooks)
|
||||
- `--stdin` — Read hook data from STDIN
|
||||
- `--tool <name>` — Tool identifier (e.g. claude, claude-internal)
|
||||
|
||||
## hook-dispatch
|
||||
|
||||
- `teamai hook-dispatch <event>` — Unified hook dispatcher — handles all teamai hooks for a given event in one process
|
||||
@@ -265,12 +246,6 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `--project-id <id>` — Project ID from /projects/mine
|
||||
- `--skip` — Mark current workspace as skipped (never prompt again)
|
||||
|
||||
## contribute-check
|
||||
|
||||
- `teamai contribute-check` — Check if session qualifies for contribution (called by PostToolUse hook)
|
||||
- `--stdin` — Read hook data from STDIN
|
||||
- `--tool <name>` — Tool identifier (e.g. claude, claude-internal)
|
||||
|
||||
## contribute
|
||||
|
||||
- `teamai contribute` — Contribute session knowledge to team repo
|
||||
@@ -301,12 +276,6 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `--category <cat>` — Target category: skills | rules | docs
|
||||
- `--dry-run` — Show what would be done without making changes
|
||||
|
||||
## todowrite-hint
|
||||
|
||||
- `teamai todowrite-hint` — Remind the agent to invoke teamai-recall when TodoWrite is used (PostToolUse hook)
|
||||
- `--stdin` — Read hook data from STDIN
|
||||
- `--tool <name>` — Source AI tool (claude / codebuddy / cursor)
|
||||
|
||||
## import
|
||||
|
||||
- `teamai import` — Import knowledge from local directories, remote repos, organizations, MRs, or iWiki
|
||||
@@ -338,12 +307,6 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `--max-bytes <n>` (hidden) — Override capacity cap for --cache-gc
|
||||
- `--stale-days <n>` (hidden) — Threshold for stale-eviction in days (default 30)
|
||||
|
||||
## mr-hint
|
||||
|
||||
- `teamai mr-hint` — Hint AI about recently merged but un-imported MRs (SessionStart hook)
|
||||
- `--stdin` — Read hook data from STDIN
|
||||
- `--tool <name>` — Source AI tool (claude / codebuddy / cursor)
|
||||
|
||||
## codebase
|
||||
|
||||
- `teamai codebase` — Inspect and maintain team-codebase outputs
|
||||
@@ -382,10 +345,3 @@ Generated: do not edit by hand. Regenerate with
|
||||
- `--write-mode <mode>` — Write strategy: direct | pending-review
|
||||
- `--output <dir>` — Write artifacts to directory
|
||||
- `--individual-comments` — Post each suggestion as separate comment with reaction/resolve support
|
||||
|
||||
## deep-enrich
|
||||
|
||||
- `teamai deep-enrich` — Run deep AI knowledge generation for an imported repo
|
||||
- `--project <slug>` — Project slug (directory name in evidence/code/)
|
||||
- `--wiki-root <path>` — Teamwiki root path
|
||||
- `--max-modules <n>` — Max modules to process (cost control)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
### Knowledge base update notes
|
||||
|
||||
- **Incremental update**: `code-to-knowledge --update` updates only the documents of changed files
|
||||
- **Incremental update**: `teamai codebase --extract <repo> --project <slug> --incremental` re-extracts only the changed files
|
||||
- **Full rebuild**: recommended after large-scale code refactoring
|
||||
- **Last updated**: `<ISO8601>`
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
## Code baseline version
|
||||
|
||||
> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `code-to-knowledge --update` for an incremental update.
|
||||
> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `teamai codebase --extract <repo> --project <slug> --incremental` for an incremental update.
|
||||
|
||||
- **Commit**: `<git commit SHA>`
|
||||
- **Tag**: `<tag or "no tag">`
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const autoDetectInit = vi.fn();
|
||||
vi.mock('../config.js', () => ({ autoDetectInit }));
|
||||
vi.mock('../config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../config.js')>()),
|
||||
autoDetectInit,
|
||||
}));
|
||||
|
||||
import { resolveServableSkill, skillCatalog, skillGet, skillPath } from '../skill-content.js';
|
||||
|
||||
@@ -150,10 +153,24 @@ describe('recall gate on served skills', () => {
|
||||
});
|
||||
|
||||
it('fails open when there is no team config to consult', async () => {
|
||||
autoDetectInit.mockRejectedValue(new Error('not initialized'));
|
||||
const { NotInitializedError } = await import('../config.js');
|
||||
autoDetectInit.mockRejectedValue(new NotInitializedError('teamai is not initialized. Run `teamai init` first.'));
|
||||
|
||||
// A fresh machine reading the docs gets the content, not a refusal it
|
||||
// cannot act on.
|
||||
expect((await resolveServableSkill('share')).kind).toBe('found');
|
||||
});
|
||||
|
||||
it('blocks share when a config exists but cannot be loaded, since recall and the source are then unknown', async () => {
|
||||
autoDetectInit.mockRejectedValue(new Error('Team config (teamai.yaml) not found. Check your repo path.'));
|
||||
|
||||
expect(await resolveServableSkill('share')).toEqual({ kind: 'blocked', name: 'share', reason: 'config' });
|
||||
await skillGet(['share']);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(stdout).toBe('');
|
||||
expect(stderr).toContain('config on this machine could not be loaded');
|
||||
expect((await skillCatalog()).find((entry) => entry.name === 'share')).toMatchObject({ blockedBy: 'config', path: null });
|
||||
// Only share depends on the config; the rest is still served.
|
||||
expect((await resolveServableSkill('core')).kind).toBe('found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,8 @@ function captureLogs() {
|
||||
}
|
||||
|
||||
async function runSkillShow(name: string, fx: Fixture): Promise<string[]> {
|
||||
vi.doMock('../config.js', () => ({
|
||||
vi.doMock('../config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../config.js')>()),
|
||||
autoDetectInit: async () => ({ localConfig: fx.localConfig, teamConfig: fx.teamConfig }),
|
||||
}));
|
||||
const { skillShow } = await import('../skill-cmd.js');
|
||||
|
||||
@@ -976,6 +976,50 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => {
|
||||
expect(bodies.sort()).toEqual([wikiSkillTagged('project'), wikiSkillTagged('user')]);
|
||||
});
|
||||
|
||||
it('keeps the legacy skills when the stub could not be deployed, so the agent keeps one to discover', async () => {
|
||||
const { deployBuiltinSkills } = await import('../builtin-skills.js');
|
||||
|
||||
// The stub destination is a link, so the stub is refused; pruning first
|
||||
// would leave the agent with neither the old skills nor the new one.
|
||||
const outside = path.join(tmpDir, 'outside/teamai');
|
||||
await fse.ensureDir(outside);
|
||||
await fse.ensureDir(path.join(homeDir, '.claude/skills/team-wiki-codebase'));
|
||||
await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL);
|
||||
await fse.symlink(outside, path.join(homeDir, '.claude/skills/teamai'), 'dir');
|
||||
|
||||
const deployed = await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir));
|
||||
|
||||
expect(deployed).toBe(0);
|
||||
expect(await fse.readFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL);
|
||||
});
|
||||
|
||||
it('reports a legacy directory it emptied but could not remove, instead of calling it removed', async () => {
|
||||
if (process.getuid?.() === 0) return; // root ignores directory permissions
|
||||
const { deployBuiltinSkills } = await import('../builtin-skills.js');
|
||||
const { log } = await import('../utils/logger.js');
|
||||
|
||||
// A locked skills root: the files inside the legacy directory can go, the
|
||||
// directory itself cannot. The stub directory already exists, so the stub
|
||||
// still deploys and the prune runs.
|
||||
const skills = path.join(homeDir, '.claude/skills');
|
||||
await fse.ensureDir(path.join(skills, 'team-wiki-codebase'));
|
||||
await fse.writeFile(path.join(skills, 'team-wiki-codebase/SKILL.md'), WIKI_SKILL);
|
||||
await fse.ensureDir(path.join(skills, 'teamai'));
|
||||
await fse.writeFile(path.join(skills, 'teamai/SKILL.md'), shipped('teamai', 'SKILL.md'));
|
||||
(log.warn as ReturnType<typeof vi.fn>).mockClear();
|
||||
await fse.chmod(skills, 0o555);
|
||||
try {
|
||||
await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir));
|
||||
} finally {
|
||||
await fse.chmod(skills, 0o755);
|
||||
}
|
||||
|
||||
const warnings = (log.warn as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0]));
|
||||
expect(warnings.filter((w) => w.includes('team-wiki-codebase') && w.includes('could not be deleted'))).toHaveLength(1);
|
||||
// The stub's own directory is not empty, so it is not reported.
|
||||
expect(warnings.filter((w) => w.includes(path.join(skills, 'teamai')))).toEqual([]);
|
||||
});
|
||||
|
||||
it('archives nothing when there is nothing retired to archive', async () => {
|
||||
const { deployBuiltinSkills } = await import('../builtin-skills.js');
|
||||
|
||||
|
||||
+36
-11
@@ -160,20 +160,36 @@ async function walkFiles(dir: string, prefix = ''): Promise<string[]> {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Remove `dir` and every directory under it that holds nothing. */
|
||||
async function removeEmptyDirs(dir: string): Promise<void> {
|
||||
/**
|
||||
* Remove `dir` and every directory under it that holds nothing. A directory
|
||||
* that still has something in it stays, which is the point: that something is
|
||||
* the member's. Any other failure (permissions, a busy mount) is returned, so
|
||||
* the prune does not report a directory gone that is still there.
|
||||
*/
|
||||
async function removeEmptyDirs(dir: string): Promise<{ file: string; error: string }[]> {
|
||||
const failures: { file: string; error: string }[] = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') failures.push({ file: dir, error: (e as Error).message });
|
||||
return failures;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name));
|
||||
if (entry.isDirectory()) failures.push(...await removeEmptyDirs(path.join(dir, entry.name)));
|
||||
}
|
||||
// Fails when something is left, which is the point: that something is the
|
||||
// member's, and their directory stays.
|
||||
try { await fs.promises.rmdir(dir); } catch { /* not empty */ }
|
||||
try {
|
||||
await fs.promises.rmdir(dir);
|
||||
} catch (e) {
|
||||
// Not empty is the expected outcome for a directory holding the member's
|
||||
// files — and some platforms say EACCES for that under a read-only parent,
|
||||
// so the directory's contents, not the error code, decide.
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
const stillHolds = code === 'ENOTEMPTY' || code === 'EEXIST'
|
||||
|| (await fs.promises.readdir(dir).catch(() => [])).length > 0;
|
||||
if (!stillHolds) failures.push({ file: dir, error: (e as Error).message });
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
/** What `removeOwnedFiles` did and did not do, for the caller to report. */
|
||||
@@ -279,7 +295,7 @@ export async function removeOwnedFiles(
|
||||
result.notRemoved.push({ file, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
await removeEmptyDirs(dir);
|
||||
result.notRemoved.push(...await removeEmptyDirs(dir));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -525,8 +541,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?
|
||||
// legacy directories are left alone too.
|
||||
if (localConfig && isAgentExcluded(localConfig, tool)) continue;
|
||||
|
||||
await pruneLegacyBuiltinSkills(tool, target);
|
||||
|
||||
let deployedHere = 0;
|
||||
for (const skillName of skillNames) {
|
||||
const srcDir = path.join(builtinDir, skillName);
|
||||
const destDir = localConfig
|
||||
@@ -566,10 +581,20 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?
|
||||
if (tool === CODEX_TOOL) await retireOtherCodexCopy(tool, skillName, destDir, target);
|
||||
|
||||
deployed++;
|
||||
deployedHere++;
|
||||
} catch (e) {
|
||||
log.error(`Failed to deploy built-in skill ${skillName} to ${toolPath.skills}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The legacy trees go only once their replacement is in place: pruning first
|
||||
// and then failing to write the stub (a link, a read-only directory) would
|
||||
// leave the agent with no discoverable TeamAI skill at all.
|
||||
if (deployedHere === skillNames.length) {
|
||||
await pruneLegacyBuiltinSkills(tool, target);
|
||||
} else {
|
||||
log.warn(`Kept the pre-stub skills for ${tool}: the new stub was not deployed there, so removing them would leave nothing to discover.`);
|
||||
}
|
||||
}
|
||||
|
||||
return deployed;
|
||||
|
||||
@@ -37,6 +37,15 @@ function visibleOptions(command: Command): Option[] {
|
||||
return command.options.filter((option) => option.long !== '--help');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcommands `--help` lists. Hidden ones (`track`, `contribute-check`, …) are
|
||||
* hook plumbing the CLI calls itself; listing them would advertise them to the
|
||||
* agent as supported commands. The implicit `help` entry says nothing.
|
||||
*/
|
||||
function visibleSubcommands(command: Command): Command[] {
|
||||
return command.createHelp().visibleCommands(command).filter((sub) => sub.name() !== 'help');
|
||||
}
|
||||
|
||||
function renderCommand(command: Command, parents: string[]): string[] {
|
||||
const path = [...parents, command.name()];
|
||||
const args = command.registeredArguments.map((a) => {
|
||||
@@ -51,7 +60,7 @@ function renderCommand(command: Command, parents: string[]): string[] {
|
||||
for (const option of visibleOptions(command)) {
|
||||
lines.push(renderOption(option));
|
||||
}
|
||||
for (const sub of command.commands) {
|
||||
for (const sub of visibleSubcommands(command)) {
|
||||
lines.push(...renderCommand(sub, path).map((line) => ` ${line}`));
|
||||
}
|
||||
return lines;
|
||||
@@ -66,7 +75,7 @@ export function renderCommandsReference(program: Command): string {
|
||||
sections.push(['## Global options', '', ...globalOptions.map(renderOption).map((l) => l.slice(2))].join('\n'));
|
||||
}
|
||||
|
||||
for (const command of program.commands) {
|
||||
for (const command of visibleSubcommands(program)) {
|
||||
sections.push([`## ${command.name()}`, '', ...renderCommand(command, [])].join('\n'));
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -175,7 +175,8 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr
|
||||
} else {
|
||||
for (const entry of catalog) {
|
||||
const note = entry.blockedBy === 'recall' ? ' (needs recall — teamai recall enable)'
|
||||
: entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)' : '';
|
||||
: entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)'
|
||||
: entry.blockedBy === 'config' ? ' (not available: the teamai config could not be loaded)' : '';
|
||||
console.log(` ${entry.name}${note}`);
|
||||
console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`);
|
||||
console.log(` teamai skill get ${entry.name}`);
|
||||
|
||||
+13
-5
@@ -63,16 +63,19 @@ const SKILL_ALIASES: Readonly<Record<string, string>> = {
|
||||
const RECALL_DEPENDENT_SKILLS = new Set(['share']);
|
||||
|
||||
/** Why a served skill is withheld right now. */
|
||||
export type SkillBlockReason = 'recall' | 'read-only';
|
||||
export type SkillBlockReason = 'recall' | 'read-only' | 'config';
|
||||
|
||||
/**
|
||||
* What makes this skill unusable right now, or null.
|
||||
*
|
||||
* Fails open: a machine with no team config (a fresh install reading the docs)
|
||||
* gets the content rather than a refusal it cannot act on.
|
||||
* Fails open only where there is no config at all: a fresh install reading
|
||||
* the docs gets the content rather than a refusal it cannot act on. A config
|
||||
* that exists but cannot be loaded blocks: whether recall is on, or the source
|
||||
* writable, is then unknown, and the workflow would fail at `teamai contribute`.
|
||||
*/
|
||||
async function blockReason(name: string): Promise<SkillBlockReason | null> {
|
||||
if (!RECALL_DEPENDENT_SKILLS.has(name)) return null;
|
||||
const { NotInitializedError } = await import('./config.js');
|
||||
try {
|
||||
const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([
|
||||
import('./config.js'),
|
||||
@@ -93,8 +96,8 @@ async function blockReason(name: string): Promise<SkillBlockReason | null> {
|
||||
// workflow would fail at its last step after the agent did all the work.
|
||||
if (localConfig.repo?.kind === 'http') return 'read-only';
|
||||
return isRecallEnabled(localConfig, teamConfig) ? null : 'recall';
|
||||
} catch {
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e instanceof NotInitializedError ? null : 'config';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +227,11 @@ export function blockMessage(name: string, reason: SkillBlockReason): { headline
|
||||
headline: `${name} is not available: this team uses a read-only HTTP source, so nothing can be contributed from here.`,
|
||||
hint: 'Ask a team admin to add the learning to the team repo.',
|
||||
};
|
||||
case 'config':
|
||||
return {
|
||||
headline: `${name} is not available: the teamai config on this machine could not be loaded, so whether it can contribute is unknown.`,
|
||||
hint: 'Run `teamai doctor` to see what is wrong with it, then try again.',
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = reason;
|
||||
throw new Error(`Unhandled block reason ${String(exhaustive)}`);
|
||||
|
||||
Reference in New Issue
Block a user