Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 37 additions & 14 deletions src/core/PluginRescope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginRescope } from "#core/PluginRescope.js";
import { ClaudeCodeToolbox } from "#core/ClaudeCodeToolbox.js";
import { ConfigNotFoundError } from "#util/ConfigNotFoundError.js";
import { divider, section } from "#util/format-output.js";
import { FormatOutput } from "#util/FormatOutput.js";
import { JsonConfig } from "#util/JsonConfig.js";
import { getHelpText } from "#util/get-help-text.js";

Expand Down Expand Up @@ -757,7 +757,7 @@ describe("PluginRescope", () => {
});

describe("output formatting", () => {
it("outputs a section block with the plugin name", () => {
it("outputs a header with the plugin name", () => {
vi.mocked(
ClaudeCodeToolbox.prototype.validateInstallation,
).mockReturnValue("1.0.27");
Expand All @@ -769,12 +769,10 @@ describe("PluginRescope", () => {
rescope.rescope(["my-plugin@owner"]);

const output = allOutput();
for (const line of section("my-plugin@owner")) {
expect(output).toContain(line);
}
expect(output).toContain(FormatOutput.header("my-plugin@owner"));
});

it("outputs a section block for each plugin when processing multiple", () => {
it("outputs a header for each plugin when processing multiple", () => {
vi.mocked(
ClaudeCodeToolbox.prototype.validateInstallation,
).mockReturnValue("1.0.27");
Expand All @@ -786,15 +784,11 @@ describe("PluginRescope", () => {
rescope.rescope(["plugin-a@owner", "plugin-b@owner"]);

const output = allOutput();
for (const line of section("plugin-a@owner")) {
expect(output).toContain(line);
}
for (const line of section("plugin-b@owner")) {
expect(output).toContain(line);
}
expect(output).toContain(FormatOutput.header("plugin-a@owner"));
expect(output).toContain(FormatOutput.header("plugin-b@owner"));
});

it("outputs a divider even when processing a single plugin", () => {
it("outputs a footer for each plugin block", () => {
vi.mocked(
ClaudeCodeToolbox.prototype.validateInstallation,
).mockReturnValue("1.0.27");
Expand All @@ -806,7 +800,7 @@ describe("PluginRescope", () => {
rescope.rescope(["my-plugin@owner"]);

const output = allOutput();
expect(output).toContain(divider());
expect(output).toContain(FormatOutput.footer());
});

it("shows the directory name instead of the full path in rescope output", () => {
Expand Down Expand Up @@ -909,5 +903,34 @@ describe("PluginRescope", () => {
const output = allOutput();
expect(output).toContain("not found");
});

it("wraps the version check output in a header/footer block", () => {
vi.mocked(
ClaudeCodeToolbox.prototype.validateInstallation,
).mockReturnValue("1.0.27");
vi.mocked(
ClaudeCodeToolbox.prototype.getGlobalPluginConfig,
).mockReturnValue([]);

const rescope = new PluginRescope("/Users/test/project");
rescope.rescope(["my-plugin@owner"]);

const output = allOutput();
expect(output).toContain(FormatOutput.header("version check"));
expect(output).toContain(FormatOutput.footer());
});

it("wraps the not-installed output in a header/footer block", () => {
vi.mocked(
ClaudeCodeToolbox.prototype.validateInstallation,
).mockReturnValue(false);

const rescope = new PluginRescope("/Users/test/project");
rescope.rescope(["my-plugin@owner"]);

const output = allOutput();
expect(output).toContain(FormatOutput.header("version check"));
expect(output).toContain(FormatOutput.footer());
});
});
});
33 changes: 21 additions & 12 deletions src/core/PluginRescope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { homedir } from "node:os";
import { basename, join } from "node:path";
import { ClaudeCodeToolbox } from "#core/ClaudeCodeToolbox.js";
import { FlagParser } from "#util/FlagParser.js";
import { positive, negative, section } from "#util/format-output.js";
import { FormatOutput } from "#util/FormatOutput.js";
import { getHelpText } from "#util/get-help-text.js";
import { JsonConfig } from "#util/JsonConfig.js";

Expand Down Expand Up @@ -73,30 +73,33 @@ export class PluginRescope {

const version = toolbox.validateInstallation();

console.log(FormatOutput.header("version check"));
if (version === false) {
console.log(negative("Claude Code not found"));
console.log(FormatOutput.negative("Claude Code not found"));
console.log(FormatOutput.footer());
return;
}

console.log(positive(`Claude Code v${version}`));
console.log(FormatOutput.positive(`Claude Code v${version}`));
console.log(FormatOutput.footer());

const handler =
command === "add"
? (pluginName: string) => this.rescopePlugin(toolbox, pluginName, scope)
: (pluginName: string) => this.unscopePlugin(toolbox, pluginName);

for (const pluginName of pluginNames) {
for (const line of section(pluginName)) {
console.log(line);
}
console.log(FormatOutput.header(pluginName));

try {
handler(pluginName);
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "An unknown error occurred.";
console.log(negative(message));
console.log(FormatOutput.negative(message));
}

console.log(FormatOutput.footer());
}
}

Expand All @@ -113,7 +116,9 @@ export class PluginRescope {

if (bindings.length === 0) {
console.log(
negative("not found in global config. No workaround needed."),
FormatOutput.negative(
"not found in global config. No workaround needed.",
),
);
return;
}
Expand All @@ -129,7 +134,7 @@ export class PluginRescope {
const alreadyEnabled = !!enabledPlugins[pluginName];

if (alreadyBound && alreadyEnabled) {
console.log(positive("already configured"));
console.log(FormatOutput.positive("already configured"));
return;
}

Expand All @@ -151,7 +156,9 @@ export class PluginRescope {
toolbox.addLocalPlugin(pluginName);
}

console.log(positive(`rescoped to ${this.shortPath} (${scope})`));
console.log(
FormatOutput.positive(`rescoped to ${this.shortPath} (${scope})`),
);
}

/**
Expand All @@ -166,13 +173,15 @@ export class PluginRescope {

if (!isBound) {
console.log(
negative(`${pluginName} is not rescoped to ${this.shortPath}`),
FormatOutput.negative(
`${pluginName} is not rescoped to ${this.shortPath}`,
),
);
return;
}

toolbox.removeGlobalPluginBinding(pluginName, this.projectPath);
toolbox.removeLocalPlugin(pluginName);
console.log(positive(`removed from ${this.shortPath}`));
console.log(FormatOutput.positive(`removed from ${this.shortPath}`));
}
}
76 changes: 76 additions & 0 deletions src/util/FormatOutput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { FormatOutput } from "#util/FormatOutput.js";

describe("FormatOutput", () => {
describe("header", () => {
it("returns a string containing the provided label", () => {
expect(FormatOutput.header("my-plugin@owner")).toContain(
"my-plugin@owner",
);
});

it("returns a non-empty string for any input", () => {
expect(FormatOutput.header("test").length).toBeGreaterThan(0);
});

it("preserves the full label text", () => {
const label = "complex-plugin@some-org";
expect(FormatOutput.header(label)).toContain(label);
});
});

describe("footer", () => {
it("returns a non-empty string", () => {
expect(FormatOutput.footer().length).toBeGreaterThan(0);
});

it("returns the same value on repeated calls", () => {
expect(FormatOutput.footer()).toBe(FormatOutput.footer());
});
});

describe("positive", () => {
it("returns a string containing the provided message", () => {
expect(FormatOutput.positive("operation succeeded")).toContain(
"operation succeeded",
);
});

it("returns a non-empty string for any input", () => {
expect(FormatOutput.positive("hello").length).toBeGreaterThan(0);
});

it("preserves the full message text", () => {
const msg = "Claude Code v1.0.26";
expect(FormatOutput.positive(msg)).toContain(msg);
});
});

describe("negative", () => {
it("returns a string containing the provided message", () => {
expect(FormatOutput.negative("something failed")).toContain(
"something failed",
);
});

it("returns a non-empty string for any input", () => {
expect(FormatOutput.negative("error").length).toBeGreaterThan(0);
});

it("preserves the full message text", () => {
const msg = "plugin@owner not found in config";
expect(FormatOutput.negative(msg)).toContain(msg);
});
});

describe("format types produce distinct output", () => {
it("positive and negative return different results for the same message", () => {
const msg = "same message";
expect(FormatOutput.positive(msg)).not.toBe(FormatOutput.negative(msg));
});

it("header and footer return different results", () => {
expect(FormatOutput.header("label")).not.toBe(FormatOutput.footer());
});
});
});
53 changes: 53 additions & 0 deletions src/util/FormatOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import pc from "picocolors";

/**
* Formats CLI output lines using a pipe border design.
*
* Each plugin block is framed with box-drawing characters:
*
* ```
* ┌ plugin-name
* │ ✓ success message
* └─
* ```
*/
export class FormatOutput {
/**
* Returns the header line for a plugin block.
*
* @param label - The plugin name to display in bold.
* @returns Formatted header string: ` ┌ {bold label}`
*/
static header(label: string): string {
return ` ${pc.dim("\u250c")} ${pc.bold(label)}`;
}

/**
* Returns the footer line that closes a plugin block.
*
* @returns Formatted footer string: ` └─`
*/
static footer(): string {
return ` ${pc.dim("\u2514\u2500")}`;
}

/**
* Returns a positive (success) output line with a green checkmark.
*
* @param message - The message to display.
* @returns Formatted string: ` │ ✓ {green message}`
*/
static positive(message: string): string {
return ` ${pc.dim("\u2502")} ${pc.green(`\u2713 ${message}`)}`;
}

/**
* Returns a negative (error/failure) output line with a red cross.
*
* @param message - The message to display.
* @returns Formatted string: ` │ ✗ {red message}`
*/
static negative(message: string): string {
return ` ${pc.dim("\u2502")} ${pc.red(`\u2717 ${message}`)}`;
}
}
71 changes: 0 additions & 71 deletions src/util/format-output.test.ts

This file was deleted.

Loading