mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): add project approval flags
This commit is contained in:
parent
37f160cab6
commit
c3e91ceac0
10 changed files with 124 additions and 47 deletions
|
|
@ -290,7 +290,7 @@ See [docs/settings.md](docs/settings.md) for all options.
|
|||
|
||||
### Project Trust
|
||||
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--approve`/`-a` to load project `.pi` for one run regardless of trust, or `--no-approve`/`-na` to skip it for one run.
|
||||
|
||||
### Telemetry and update checks
|
||||
|
||||
|
|
@ -589,7 +589,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
|
|||
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-f`, `--force` | Force loading project `.pi` regardless of trust |
|
||||
| `-a`, `--approve` | Load project `.pi` regardless of trust |
|
||||
| `-na`, `--no-approve` | Skip project `.pi` regardless of trust |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Edit directly or use `/settings` for common options.
|
|||
|
||||
## Project Trust
|
||||
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--approve`/`-a` to load project `.pi` for one run regardless of trust, or `--no-approve`/`-na` to skip it for one run.
|
||||
|
||||
## All Settings
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in tho
|
|||
|
||||
### Project Trust
|
||||
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--approve`/`-a` to load project `.pi` for one run regardless of trust, or `--no-approve`/`-na` to skip it for one run.
|
||||
|
||||
## Exporting and Sharing Sessions
|
||||
|
||||
|
|
@ -224,7 +224,8 @@ pi --no-extensions -e ./my-extension.ts
|
|||
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-f`, `--force` | Force loading project `.pi` regardless of trust |
|
||||
| `-a`, `--approve` | Load project `.pi` regardless of trust |
|
||||
| `-na`, `--no-approve` | Skip project `.pi` regardless of trust |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export interface Args {
|
|||
listModels?: string | true;
|
||||
offline?: boolean;
|
||||
verbose?: boolean;
|
||||
force?: boolean;
|
||||
projectPiApproval?: boolean;
|
||||
messages: string[];
|
||||
fileArgs: string[];
|
||||
/** Unknown flags (potentially extension flags) - map of flag name to value */
|
||||
|
|
@ -177,8 +177,10 @@ export function parseArgs(args: string[]): Args {
|
|||
}
|
||||
} else if (arg === "--verbose") {
|
||||
result.verbose = true;
|
||||
} else if (arg === "--force" || arg === "-f") {
|
||||
result.force = true;
|
||||
} else if (arg === "--approve" || arg === "-a") {
|
||||
result.projectPiApproval = true;
|
||||
} else if (arg === "--no-approve" || arg === "-na") {
|
||||
result.projectPiApproval = false;
|
||||
} else if (arg === "--offline") {
|
||||
result.offline = true;
|
||||
} else if (arg.startsWith("@")) {
|
||||
|
|
@ -228,8 +230,10 @@ ${chalk.bold("Commands:")}
|
|||
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||
${APP_NAME} uninstall <source> [-l] Alias for remove
|
||||
${APP_NAME} update [source|self|pi] Update pi and installed extensions
|
||||
${APP_NAME} list [--force] List installed extensions from settings
|
||||
${APP_NAME} config [--force] Open TUI to enable/disable package resources
|
||||
${APP_NAME} list [--approve|--no-approve]
|
||||
List installed extensions from settings
|
||||
${APP_NAME} config [--approve|--no-approve]
|
||||
Open TUI to enable/disable package resources
|
||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
|
|
@ -269,7 +273,8 @@ ${chalk.bold("Options:")}
|
|||
--export <file> Export session file to HTML and exit
|
||||
--list-models [search] List available models (with optional fuzzy search)
|
||||
--verbose Force verbose startup (overrides quietStartup setting)
|
||||
--force, -f Force loading project .pi
|
||||
--approve, -a Load project .pi for this run regardless of trust
|
||||
--no-approve, -na Skip project .pi for this run regardless of trust
|
||||
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
||||
--help, -h Show this help
|
||||
--version, -v Show version number
|
||||
|
|
|
|||
|
|
@ -498,12 +498,12 @@ async function promptForProjectTrust(cwd: string, settingsManager: SettingsManag
|
|||
async function resolveProjectPiTrusted(options: {
|
||||
cwd: string;
|
||||
trustStore: ProjectTrustStore;
|
||||
force: boolean;
|
||||
projectPiApproval?: boolean;
|
||||
appMode: AppMode;
|
||||
settingsManagerForPrompt: SettingsManager;
|
||||
}): Promise<boolean> {
|
||||
if (options.force) {
|
||||
return true;
|
||||
if (options.projectPiApproval !== undefined) {
|
||||
return options.projectPiApproval;
|
||||
}
|
||||
if (!hasProjectPiDirectory(options.cwd)) {
|
||||
return false;
|
||||
|
|
@ -597,12 +597,12 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
const agentDir = getAgentDir();
|
||||
const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false });
|
||||
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
|
||||
const forceProjectPiTrust = parsed.force === true;
|
||||
const projectPiApproval = parsed.projectPiApproval;
|
||||
const trustStore = new ProjectTrustStore(agentDir);
|
||||
const startupProjectPiTrusted = await resolveProjectPiTrusted({
|
||||
cwd,
|
||||
trustStore,
|
||||
force: forceProjectPiTrust,
|
||||
projectPiApproval,
|
||||
appMode: trustPromptMode,
|
||||
settingsManagerForPrompt: promptSettingsManager,
|
||||
});
|
||||
|
|
@ -655,7 +655,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
await resolveProjectPiTrusted({
|
||||
cwd: initialRuntimeCwd,
|
||||
trustStore,
|
||||
force: forceProjectPiTrust,
|
||||
projectPiApproval,
|
||||
appMode: trustPromptMode,
|
||||
settingsManagerForPrompt: promptSettingsManager,
|
||||
});
|
||||
|
|
@ -671,7 +671,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
sessionManager,
|
||||
sessionStartEvent,
|
||||
}) => {
|
||||
const projectPiTrusted = forceProjectPiTrust || (hasProjectPiDirectory(cwd) && trustStore.get(cwd) === true);
|
||||
const projectPiTrusted = projectPiApproval ?? (hasProjectPiDirectory(cwd) && trustStore.get(cwd) === true);
|
||||
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
|
|
@ -832,7 +832,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
initialImages,
|
||||
initialMessages: parsed.messages,
|
||||
verbose: parsed.verbose,
|
||||
forceProjectPiTrust,
|
||||
projectPiApproval,
|
||||
});
|
||||
if (startupBenchmark) {
|
||||
await interactiveMode.init();
|
||||
|
|
|
|||
|
|
@ -256,8 +256,8 @@ export interface InteractiveModeOptions {
|
|||
initialMessages?: string[];
|
||||
/** Force verbose startup (overrides quietStartup setting) */
|
||||
verbose?: boolean;
|
||||
/** Force project .pi trust for this process */
|
||||
forceProjectPiTrust?: boolean;
|
||||
/** Override project .pi trust for this process */
|
||||
projectPiApproval?: boolean;
|
||||
}
|
||||
|
||||
export class InteractiveMode {
|
||||
|
|
@ -5000,7 +5000,7 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
trustStore.set(cwd, decision);
|
||||
const projectPiTrusted = this.options.forceProjectPiTrust === true || decision === true;
|
||||
const projectPiTrusted = this.options.projectPiApproval ?? decision === true;
|
||||
this.settingsManager.setProjectConfigTrusted(projectPiTrusted);
|
||||
await this.handleReloadCommand();
|
||||
this.showStatus(`Project trust: ${this.formatTrustDecision(decision)}`);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ interface PackageCommandOptions {
|
|||
updateTarget?: UpdateTarget;
|
||||
local: boolean;
|
||||
force: boolean;
|
||||
projectPiApproval?: boolean;
|
||||
help: boolean;
|
||||
invalidOption?: string;
|
||||
invalidArgument?: string;
|
||||
|
|
@ -73,9 +74,9 @@ function getPackageCommandUsage(command: PackageCommand): string {
|
|||
case "remove":
|
||||
return `${APP_NAME} remove <source> [-l]`;
|
||||
case "update":
|
||||
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`;
|
||||
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--approve|--no-approve] [--force]`;
|
||||
case "list":
|
||||
return `${APP_NAME} list [--force]`;
|
||||
return `${APP_NAME} list [--approve|--no-approve]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,8 +89,9 @@ function printPackageCommandHelp(command: PackageCommand): void {
|
|||
Install a package and add it to settings.
|
||||
|
||||
Options:
|
||||
-l, --local Install project-locally (.pi/settings.json)
|
||||
-f, --force Trust project .pi for this command
|
||||
-l, --local Install project-locally (.pi/settings.json)
|
||||
-a, --approve Load project .pi for this command
|
||||
-na, --no-approve Skip project .pi for this command
|
||||
|
||||
Examples:
|
||||
${APP_NAME} install npm:@foo/bar
|
||||
|
|
@ -109,8 +111,9 @@ Remove a package and its source from settings.
|
|||
Alias: ${APP_NAME} uninstall <source> [-l]
|
||||
|
||||
Options:
|
||||
-l, --local Remove from project settings (.pi/settings.json)
|
||||
-f, --force Trust project .pi for this command
|
||||
-l, --local Remove from project settings (.pi/settings.json)
|
||||
-a, --approve Load project .pi for this command
|
||||
-na, --no-approve Skip project .pi for this command
|
||||
|
||||
Examples:
|
||||
${APP_NAME} remove npm:@foo/bar
|
||||
|
|
@ -128,6 +131,8 @@ Options:
|
|||
--self Update pi only
|
||||
--extensions Update installed packages only
|
||||
--extension <source> Update one package only
|
||||
-a, --approve Load project .pi for this command
|
||||
-na, --no-approve Skip project .pi for this command
|
||||
--force Reinstall pi even if the current version is latest
|
||||
|
||||
Short forms:
|
||||
|
|
@ -144,7 +149,8 @@ Short forms:
|
|||
List installed packages from user and project settings.
|
||||
|
||||
Options:
|
||||
-f, --force Trust project .pi for this command
|
||||
-a, --approve Load project .pi for this command
|
||||
-na, --no-approve Skip project .pi for this command
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -164,6 +170,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||
|
||||
let local = false;
|
||||
let force = false;
|
||||
let projectPiApproval: boolean | undefined;
|
||||
let help = false;
|
||||
let invalidOption: string | undefined;
|
||||
let invalidArgument: string | undefined;
|
||||
|
|
@ -208,8 +215,22 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--force" || arg === "-f") {
|
||||
force = true;
|
||||
if (arg === "--approve" || arg === "-a") {
|
||||
projectPiApproval = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--no-approve" || arg === "-na") {
|
||||
projectPiApproval = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--force") {
|
||||
if (command === "update") {
|
||||
force = true;
|
||||
} else {
|
||||
invalidOption = invalidOption ?? arg;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -282,6 +303,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||
updateTarget,
|
||||
local,
|
||||
force,
|
||||
projectPiApproval,
|
||||
help,
|
||||
invalidOption,
|
||||
invalidArgument,
|
||||
|
|
@ -391,6 +413,18 @@ function prepareWindowsNpmSelfUpdate(): void {
|
|||
quarantineWindowsNativeDependencies(packageDir);
|
||||
}
|
||||
|
||||
function parseProjectPiApproval(args: readonly string[]): boolean | undefined {
|
||||
let approval: boolean | undefined;
|
||||
for (const arg of args) {
|
||||
if (arg === "--approve" || arg === "-a") {
|
||||
approval = true;
|
||||
} else if (arg === "--no-approve" || arg === "-na") {
|
||||
approval = false;
|
||||
}
|
||||
}
|
||||
return approval;
|
||||
}
|
||||
|
||||
export async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||
if (args[0] !== "config") {
|
||||
return false;
|
||||
|
|
@ -399,11 +433,9 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
|
|||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const projectPiExists = hasProjectPiDirectory(cwd);
|
||||
const projectPiApproval = parseProjectPiApproval(args);
|
||||
const projectPiTrusted =
|
||||
!projectPiExists ||
|
||||
args.includes("--force") ||
|
||||
args.includes("-f") ||
|
||||
new ProjectTrustStore(agentDir).get(cwd) === true;
|
||||
projectPiApproval ?? (!projectPiExists || new ProjectTrustStore(agentDir).get(cwd) === true);
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
|
||||
reportSettingsErrors(settingsManager, "config command");
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
|
|
@ -470,14 +502,12 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
|
|||
const agentDir = getAgentDir();
|
||||
const projectPiExists = hasProjectPiDirectory(cwd);
|
||||
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
|
||||
const commandForcesProjectPiTrust =
|
||||
options.force && (options.command === "install" || options.command === "remove" || options.command === "list");
|
||||
const projectPiTrusted =
|
||||
commandForcesProjectPiTrust ||
|
||||
(projectPiExists && new ProjectTrustStore(agentDir).get(cwd) === true) ||
|
||||
(writesProjectPackageConfig && !projectPiExists);
|
||||
if (!projectPiTrusted && projectPiExists && writesProjectPackageConfig) {
|
||||
console.error(chalk.red("Project .pi is not trusted. Use --force to modify local package config."));
|
||||
options.projectPiApproval ??
|
||||
((projectPiExists && new ProjectTrustStore(agentDir).get(cwd) === true) ||
|
||||
(writesProjectPackageConfig && !projectPiExists));
|
||||
if (!projectPiTrusted && writesProjectPackageConfig) {
|
||||
console.error(chalk.red("Project .pi is not trusted. Use --approve to modify local package config."));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,6 +293,28 @@ describe("parseArgs", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("project .pi approval flags", () => {
|
||||
test("parses --approve", () => {
|
||||
const result = parseArgs(["--approve"]);
|
||||
expect(result.projectPiApproval).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -a shorthand", () => {
|
||||
const result = parseArgs(["-a"]);
|
||||
expect(result.projectPiApproval).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --no-approve", () => {
|
||||
const result = parseArgs(["--no-approve"]);
|
||||
expect(result.projectPiApproval).toBe(false);
|
||||
});
|
||||
|
||||
test("parses -na shorthand", () => {
|
||||
const result = parseArgs(["-na"]);
|
||||
expect(result.projectPiApproval).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--verbose flag", () => {
|
||||
test("parses --verbose flag", () => {
|
||||
const result = parseArgs(["--verbose"]);
|
||||
|
|
|
|||
|
|
@ -102,13 +102,13 @@ describe("package commands", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("forces project trust for list with --force", async () => {
|
||||
it("approves project trust for list with --approve", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["list", "--force"])).resolves.toBeUndefined();
|
||||
await expect(main(["list", "--approve"])).resolves.toBeUndefined();
|
||||
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).toContain("Project packages:");
|
||||
|
|
@ -139,6 +139,24 @@ describe("package commands", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("disapproves remembered project trust for list with --no-approve", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
|
||||
new ProjectTrustStore(agentDir).set(projectDir, true);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["list", "--no-approve"])).resolves.toBeUndefined();
|
||||
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).toContain("No packages installed.");
|
||||
expect(stdout).not.toContain("Project packages:");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks local package changes when project .pi is untrusted", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
|
@ -147,7 +165,7 @@ describe("package commands", () => {
|
|||
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
|
||||
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain("Project .pi is not trusted. Use --force to modify local package config.");
|
||||
expect(stderr).toContain("Project .pi is not trusted. Use --approve to modify local package config.");
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
|
|||
|
||||
describe("stdout cleanliness in non-interactive modes", () => {
|
||||
it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => {
|
||||
const result = await runCli(["--mode", "json", "--force", "--help"]);
|
||||
const result = await runCli(["--mode", "json", "--approve", "--help"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe("");
|
||||
|
|
@ -91,7 +91,7 @@ describe("stdout cleanliness in non-interactive modes", () => {
|
|||
});
|
||||
|
||||
it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => {
|
||||
const result = await runCli(["-p", "--force", "--help"]);
|
||||
const result = await runCli(["-p", "--approve", "--help"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe("");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue