@canonical/pragma-cli 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -15
- package/dist/pragma +0 -0
- package/package.json +13 -13
- package/src/config/defaultConfig.test.ts +33 -0
- package/src/config/defaultConfig.ts +59 -0
- package/src/config/defaults.json +17 -0
- package/src/config/findProjectConfigPath.test.ts +53 -0
- package/src/config/findProjectConfigPath.ts +36 -0
- package/src/config/index.ts +17 -2
- package/src/config/mergeConfigFileUpdate.ts +58 -0
- package/src/config/parseConfigValues.ts +215 -0
- package/src/config/readConfig.test.ts +104 -13
- package/src/config/readConfig.ts +12 -153
- package/src/config/readConfigLayers.test.ts +107 -0
- package/src/config/readConfigLayers.ts +115 -0
- package/src/config/resolveGlobalConfigPath.ts +13 -0
- package/src/config/resolveWriteConfigPath.test.ts +53 -0
- package/src/config/resolveWriteConfigPath.ts +31 -0
- package/src/config/types.ts +76 -1
- package/src/config/writeConfig.ts +25 -55
- package/src/domains/block/commands/list.ts +5 -90
- package/src/domains/block/commands/lookup.ts +8 -167
- package/src/domains/block/mcp/specs.ts +16 -196
- package/src/domains/block/orchestration/resolveBlockLookup.ts +2 -1
- package/src/domains/block/stories.test.ts +28 -0
- package/src/domains/block/stories.ts +254 -0
- package/src/domains/config/commands/channel.test.ts +14 -3
- package/src/domains/config/commands/channel.ts +11 -7
- package/src/domains/config/commands/configScope.ts +46 -0
- package/src/domains/config/commands/framework.test.ts +18 -1
- package/src/domains/config/commands/framework.ts +13 -7
- package/src/domains/config/commands/show.ts +5 -45
- package/src/domains/config/commands/tier.test.ts +13 -2
- package/src/domains/config/commands/tier.ts +11 -7
- package/src/domains/config/commands/trace.test.ts +19 -2
- package/src/domains/config/commands/trace.ts +6 -3
- package/src/domains/config/formatters/channel.test.ts +20 -6
- package/src/domains/config/formatters/channel.ts +14 -11
- package/src/domains/config/formatters/framework.ts +14 -11
- package/src/domains/config/formatters/show.test.ts +27 -0
- package/src/domains/config/formatters/show.ts +27 -4
- package/src/domains/config/formatters/tier.test.ts +26 -10
- package/src/domains/config/formatters/tier.ts +19 -11
- package/src/domains/config/formatters/trace.ts +7 -7
- package/src/domains/config/mcp/specs.ts +33 -53
- package/src/domains/config/operations/show.test.ts +18 -0
- package/src/domains/config/operations/show.ts +7 -1
- package/src/domains/config/operations/types.ts +7 -0
- package/src/domains/config/stories.ts +42 -0
- package/src/domains/config/tasks/index.ts +1 -0
- package/src/domains/config/tasks/setChannel.ts +13 -28
- package/src/domains/config/tasks/setFramework.ts +11 -30
- package/src/domains/config/tasks/setTier.ts +10 -29
- package/src/domains/config/tasks/setTrace.ts +10 -28
- package/src/domains/config/tasks/writeConfigField.ts +41 -0
- package/src/domains/create/commands/component.ts +5 -5
- package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
- package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
- package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
- package/src/domains/doctor/operations/checks/checkPackageRefs.ts +2 -14
- package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
- package/src/domains/doctor/operations/checks/index.ts +1 -0
- package/src/domains/doctor/operations/checks.test.ts +158 -7
- package/src/domains/doctor/operations/runChecks.test.ts +15 -6
- package/src/domains/doctor/operations/runChecks.ts +2 -0
- package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
- package/src/domains/graph/helpers/resolveUri.ts +28 -21
- package/src/domains/graphql/commands/build.test.ts +5 -3
- package/src/domains/graphql/commands/build.ts +8 -16
- package/src/domains/graphql/commands/check.test.ts +5 -3
- package/src/domains/graphql/commands/check.ts +7 -13
- package/src/domains/graphql/commands/index.ts +1 -0
- package/src/domains/graphql/commands/serve.test.ts +40 -0
- package/src/domains/graphql/commands/serve.ts +131 -0
- package/src/domains/graphql/constants.ts +10 -0
- package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
- package/src/domains/graphql/helpers/gatherSources.ts +58 -0
- package/src/domains/graphql/helpers/index.ts +2 -0
- package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
- package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
- package/src/domains/graphql/index.ts +9 -6
- package/src/domains/graphql/operations/compileSchema.test.ts +21 -45
- package/src/domains/graphql/operations/compileSchema.ts +23 -25
- package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
- package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
- package/src/domains/graphql/operations/index.ts +5 -0
- package/src/domains/info/operations/collectInfo.test.ts +14 -3
- package/src/domains/info/operations/collectInfo.ts +12 -15
- package/src/domains/llm/capabilities.test.ts +4 -4
- package/src/domains/llm/data/toolCatalog.ts +6 -0
- package/src/domains/modifier/commands/list.ts +5 -38
- package/src/domains/modifier/commands/lookup.ts +8 -79
- package/src/domains/modifier/mcp/specs.ts +15 -94
- package/src/domains/modifier/orchestration/resolveModifierLookup.ts +2 -1
- package/src/domains/modifier/stories.test.ts +20 -0
- package/src/domains/modifier/stories.ts +77 -0
- package/src/domains/ontology/commands/list.ts +5 -58
- package/src/domains/ontology/commands/show.ts +6 -58
- package/src/domains/ontology/formatters/show.ts +7 -1
- package/src/domains/ontology/mcp/specs.ts +7 -84
- package/src/domains/ontology/stories.test.ts +36 -0
- package/src/domains/ontology/stories.ts +108 -0
- package/src/domains/refs/operations/updateRefs.ts +2 -37
- package/src/domains/setup/commands/skills.test.ts +6 -2
- package/src/domains/setup/commands/skills.ts +6 -2
- package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
- package/src/domains/setup/helpers/runSetupTask.ts +62 -70
- package/src/domains/setup/operations/setupMcp.test.ts +6 -1
- package/src/domains/shared/bootStore.ts +6 -3
- package/src/domains/shared/compactUri.test.ts +11 -0
- package/src/domains/shared/compactUri.ts +10 -5
- package/src/domains/shared/context.ts +2 -2
- package/src/domains/shared/contracts.ts +11 -0
- package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
- package/src/domains/shared/createInteractivePromptSession.ts +236 -0
- package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
- package/src/domains/shared/loaders/bundledLoader.ts +110 -12
- package/src/domains/shared/loaders/gitLoader.ts +2 -2
- package/src/domains/shared/loaders/index.ts +4 -1
- package/src/domains/shared/loaders/localLoader.ts +18 -4
- package/src/domains/shared/loaders/readPackageDir.ts +36 -1
- package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
- package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
- package/src/domains/shared/lookupMany.test.ts +120 -0
- package/src/domains/shared/lookupMany.ts +34 -2
- package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
- package/src/domains/shared/mergeAndParseRefs.ts +44 -0
- package/src/domains/shared/packages.ts +10 -17
- package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
- package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
- package/src/domains/shared/runtime.ts +4 -63
- package/src/domains/shared/semanticPackage.ts +17 -1
- package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
- package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
- package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
- package/src/domains/shared/stories/compileLookupTool.ts +96 -0
- package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
- package/src/domains/shared/stories/compileReadCommand.ts +48 -0
- package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
- package/src/domains/shared/stories/compileReadTool.ts +38 -0
- package/src/domains/shared/stories/condense.test.ts +18 -0
- package/src/domains/shared/stories/condense.ts +15 -0
- package/src/domains/shared/stories/index.ts +20 -0
- package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
- package/src/domains/shared/stories/normalizeNames.ts +22 -0
- package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
- package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
- package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
- package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
- package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
- package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
- package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
- package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
- package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
- package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
- package/src/domains/shared/stories/pack/index.ts +28 -0
- package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
- package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
- package/src/domains/shared/stories/pack/types.ts +104 -0
- package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
- package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
- package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
- package/src/domains/shared/stories/projectCliParameters.ts +57 -0
- package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
- package/src/domains/shared/stories/projectToolParams.ts +32 -0
- package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
- package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
- package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
- package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
- package/src/domains/shared/stories/types.ts +171 -0
- package/src/domains/shared/suggestions/expandGlob.ts +1 -0
- package/src/domains/shared/suggestions/suggestNames.ts +43 -10
- package/src/domains/skill/commands/index.ts +3 -1
- package/src/domains/skill/commands/list.test.ts +6 -2
- package/src/domains/skill/commands/list.ts +5 -63
- package/src/domains/skill/commands/lookup.test.ts +118 -0
- package/src/domains/skill/commands/lookup.ts +14 -0
- package/src/domains/skill/formatters/index.ts +1 -0
- package/src/domains/skill/formatters/lookup.test.ts +46 -0
- package/src/domains/skill/formatters/lookup.ts +57 -0
- package/src/domains/skill/helpers/resolveSkillSources.ts +0 -1
- package/src/domains/skill/index.ts +7 -3
- package/src/domains/skill/mcp/specs.ts +10 -29
- package/src/domains/skill/operations/discover.test.ts +61 -30
- package/src/domains/skill/operations/discover.ts +28 -37
- package/src/domains/skill/operations/index.ts +2 -1
- package/src/domains/skill/operations/list.test.ts +15 -7
- package/src/domains/skill/operations/lookup.test.ts +91 -0
- package/src/domains/skill/operations/lookup.ts +70 -0
- package/src/domains/skill/stories.test.ts +43 -0
- package/src/domains/skill/stories.ts +103 -0
- package/src/domains/skill/types.ts +8 -0
- package/src/domains/standard/commands/list.ts +5 -84
- package/src/domains/standard/commands/lookup.ts +8 -93
- package/src/domains/standard/mcp/specs.ts +16 -171
- package/src/domains/standard/orchestration/resolveStandardLookup.ts +2 -1
- package/src/domains/standard/stories.test.ts +24 -0
- package/src/domains/standard/stories.ts +151 -0
- package/src/domains/tier/commands/list.ts +5 -55
- package/src/domains/tier/mcp/specs.ts +5 -31
- package/src/domains/tier/stories.test.ts +22 -0
- package/src/domains/tier/stories.ts +52 -0
- package/src/domains/token/mcp/specs.ts +5 -1
- package/src/domains/token/orchestration/resolveTokenLookup.ts +2 -1
- package/src/domains/trace/tracePlugin.test.ts +1 -1
- package/src/mcp/registerResources.test.ts +382 -74
- package/src/mcp/registerResources.ts +5 -348
- package/src/mcp/resources/buildGraphIndex.ts +167 -0
- package/src/mcp/resources/buildResourceList.test.ts +97 -0
- package/src/mcp/resources/buildResourceList.ts +176 -0
- package/src/mcp/resources/classifyEntity.test.ts +40 -0
- package/src/mcp/resources/classifyEntity.ts +27 -0
- package/src/mcp/resources/constants.ts +105 -0
- package/src/mcp/resources/displayPredicates.ts +62 -0
- package/src/mcp/resources/pickFirstValue.test.ts +29 -0
- package/src/mcp/resources/pickFirstValue.ts +26 -0
- package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
- package/src/mcp/resources/pickPrimaryType.ts +26 -0
- package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
- package/src/mcp/resources/rankUriCompletions.ts +64 -0
- package/src/mcp/resources/readEntity.ts +169 -0
- package/src/mcp/resources/registerResources.ts +126 -0
- package/src/mcp/resources/types.ts +128 -0
- package/src/mcp/tools/index.ts +9 -0
- package/src/mcp/tools/registerTools.test.ts +65 -8
- package/src/mcp/types.ts +4 -0
- package/src/pipeline/collectCommands.ts +8 -1
- package/src/pipeline/runCli.ts +40 -6
- package/src/testing/createTestMcpClient.ts +4 -1
- package/src/testing/index.ts +10 -0
- package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
- package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
- package/src/testing/integration/executor-execution.test.ts +101 -0
- package/src/testing/integration/executor-golden.test.ts +145 -0
- package/src/testing/integration/mcp-surface.test.ts +1 -1
- package/src/testing/integration/parity.test.ts +17 -12
- package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
- package/src/testing/setupXdgIsolation.ts +15 -0
- package/src/testing/standardParityFixtures.ts +92 -0
- package/src/testing/storyFixtures.ts +63 -0
- package/src/pipeline/runInteractiveCommand.ts +0 -198
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommandDefinition,
|
|
3
|
+
type CommandResult,
|
|
4
|
+
createOutputResult,
|
|
5
|
+
type ParameterDefinition,
|
|
6
|
+
} from "@canonical/cli-core";
|
|
7
|
+
import type { PragmaContext } from "../context.js";
|
|
8
|
+
import type { LookupResult } from "../contracts.js";
|
|
9
|
+
import { renderLookupResults } from "../formatters.js";
|
|
10
|
+
import normalizeNames from "./normalizeNames.js";
|
|
11
|
+
import projectCliParameters from "./projectCliParameters.js";
|
|
12
|
+
import resolveLookupDetailed from "./resolveLookupDetailed.js";
|
|
13
|
+
import type { LookupStory, LookupStoryView } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compile a lookup story into its CLI `CommandDefinition` projection.
|
|
17
|
+
*
|
|
18
|
+
* Owns the shared execute skeleton: normalize the positional `names`,
|
|
19
|
+
* reject empty input when the story defines an error, resolve the batch,
|
|
20
|
+
* then render each result through the story's formatters with the shared
|
|
21
|
+
* multi-result renderer (which appends the "Errors:" section).
|
|
22
|
+
*/
|
|
23
|
+
export default function compileLookupCommand<TDetailed, TFmtInput>(
|
|
24
|
+
ctx: PragmaContext,
|
|
25
|
+
story: LookupStory<TDetailed, TFmtInput>,
|
|
26
|
+
): CommandDefinition {
|
|
27
|
+
interface LookupOutput {
|
|
28
|
+
readonly result: LookupResult<TDetailed>;
|
|
29
|
+
readonly view: LookupStoryView;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
path: [story.noun, "lookup"],
|
|
34
|
+
description: story.description,
|
|
35
|
+
parameters: buildParameters(story),
|
|
36
|
+
...(story.parameterGroups && { parameterGroups: story.parameterGroups }),
|
|
37
|
+
meta: {
|
|
38
|
+
examples: story.examples,
|
|
39
|
+
},
|
|
40
|
+
execute: async (
|
|
41
|
+
params: Record<string, unknown>,
|
|
42
|
+
): Promise<CommandResult> => {
|
|
43
|
+
const names = normalizeNames(params.names, params.name);
|
|
44
|
+
if (names.length === 0 && story.emptyNamesError) {
|
|
45
|
+
throw story.emptyNamesError();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const view: LookupStoryView = {
|
|
49
|
+
surface: "cli",
|
|
50
|
+
detailed: resolveLookupDetailed(story, "cli", params),
|
|
51
|
+
params,
|
|
52
|
+
};
|
|
53
|
+
const result = await story.resolve(ctx, names, params);
|
|
54
|
+
const renderInk = story.renderInk;
|
|
55
|
+
|
|
56
|
+
return createOutputResult<LookupOutput>(
|
|
57
|
+
{ result, view },
|
|
58
|
+
{
|
|
59
|
+
plain: (output) =>
|
|
60
|
+
renderLookupResults({
|
|
61
|
+
ctx,
|
|
62
|
+
result: output.result,
|
|
63
|
+
formatters: story.formatters,
|
|
64
|
+
mapResult: (entity) => story.toFmtInput(entity, output.view),
|
|
65
|
+
}),
|
|
66
|
+
...(renderInk && {
|
|
67
|
+
ink: (output: LookupOutput) =>
|
|
68
|
+
renderInk(output.result, output.view),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildParameters<TDetailed, TFmtInput>(
|
|
77
|
+
story: LookupStory<TDetailed, TFmtInput>,
|
|
78
|
+
): ParameterDefinition[] {
|
|
79
|
+
const names: ParameterDefinition = {
|
|
80
|
+
name: "names",
|
|
81
|
+
description: story.namesDescription,
|
|
82
|
+
type: "multiselect",
|
|
83
|
+
positional: true,
|
|
84
|
+
required: true,
|
|
85
|
+
...(story.complete && { complete: story.complete }),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const detailed: ParameterDefinition[] = story.detailedParam
|
|
89
|
+
? [
|
|
90
|
+
{
|
|
91
|
+
name: "detailed",
|
|
92
|
+
description: story.detailedParam.description,
|
|
93
|
+
type: "boolean",
|
|
94
|
+
default: false,
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
: [];
|
|
98
|
+
|
|
99
|
+
return [names, ...detailed, ...projectCliParameters(story.params ?? [])];
|
|
100
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { LookupResult } from "../contracts.js";
|
|
3
|
+
import type { PragmaRuntime } from "../types/index.js";
|
|
4
|
+
import compileLookupTool from "./compileLookupTool.js";
|
|
5
|
+
import type { LookupStory } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface FakeEntity {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly detail: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeStory(
|
|
13
|
+
overrides: Partial<LookupStory<FakeEntity, string>> = {},
|
|
14
|
+
): LookupStory<FakeEntity, string> {
|
|
15
|
+
return {
|
|
16
|
+
noun: "thing",
|
|
17
|
+
description: "Look up things",
|
|
18
|
+
toolDescription: "Look up things over MCP.",
|
|
19
|
+
namesDescription: "Thing names",
|
|
20
|
+
namesToolDescription: "Thing names to look up",
|
|
21
|
+
detailedParam: { description: "Show details" },
|
|
22
|
+
examples: [],
|
|
23
|
+
resolve: async (_rt, names) => {
|
|
24
|
+
const results = names
|
|
25
|
+
.filter((name) => name !== "missing")
|
|
26
|
+
.map((name) => ({ name, detail: `detail-${name}` }));
|
|
27
|
+
const errors: LookupResult<FakeEntity>["errors"] = names
|
|
28
|
+
.filter((name) => name === "missing")
|
|
29
|
+
.map((name) => ({
|
|
30
|
+
query: name,
|
|
31
|
+
code: "ENTITY_NOT_FOUND",
|
|
32
|
+
message: `thing "${name}" not found.`,
|
|
33
|
+
}));
|
|
34
|
+
return { results, errors, meta: { internalErrorCount: 0 } };
|
|
35
|
+
},
|
|
36
|
+
toFmtInput: (entity, view) =>
|
|
37
|
+
view.detailed ? `${entity.name}:${entity.detail}` : entity.name,
|
|
38
|
+
formatters: {
|
|
39
|
+
plain: (text) => `plain:${text}`,
|
|
40
|
+
llm: (text) => `llm:${text}`,
|
|
41
|
+
json: (text) => JSON.stringify(text),
|
|
42
|
+
},
|
|
43
|
+
project: (entity) => ({ name: entity.name }),
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rt = {} as PragmaRuntime;
|
|
49
|
+
|
|
50
|
+
describe("compileLookupTool", () => {
|
|
51
|
+
it("derives the tool name and params", () => {
|
|
52
|
+
const tool = compileLookupTool(makeStory());
|
|
53
|
+
expect(tool.name).toBe("thing_lookup");
|
|
54
|
+
expect(tool.readOnly).toBe(true);
|
|
55
|
+
expect(Object.keys(tool.params ?? {})).toEqual([
|
|
56
|
+
"names",
|
|
57
|
+
"detailed",
|
|
58
|
+
"condensed",
|
|
59
|
+
]);
|
|
60
|
+
expect(tool.params?.names).toEqual({
|
|
61
|
+
type: "string[]",
|
|
62
|
+
description: "Thing names to look up",
|
|
63
|
+
optional: false,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns full results by default (MCP detailed defaults to true)", async () => {
|
|
68
|
+
const tool = compileLookupTool(makeStory());
|
|
69
|
+
const result = await tool.execute(rt, { names: ["a"] });
|
|
70
|
+
expect(result).toEqual({
|
|
71
|
+
data: {
|
|
72
|
+
results: [{ name: "a", detail: "detail-a" }],
|
|
73
|
+
errors: [],
|
|
74
|
+
},
|
|
75
|
+
meta: { count: 1 },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("applies the summary projection when detailed is false", async () => {
|
|
80
|
+
const tool = compileLookupTool(makeStory());
|
|
81
|
+
const result = await tool.execute(rt, {
|
|
82
|
+
names: ["a", "b"],
|
|
83
|
+
detailed: false,
|
|
84
|
+
});
|
|
85
|
+
expect(result).toEqual({
|
|
86
|
+
data: {
|
|
87
|
+
results: [{ name: "a" }, { name: "b" }],
|
|
88
|
+
errors: [],
|
|
89
|
+
},
|
|
90
|
+
meta: { count: 2 },
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("renders condensed output per entity and appends an errors section", async () => {
|
|
95
|
+
const tool = compileLookupTool(makeStory());
|
|
96
|
+
const result = await tool.execute(rt, {
|
|
97
|
+
names: ["a", "missing"],
|
|
98
|
+
condensed: true,
|
|
99
|
+
});
|
|
100
|
+
if (!("condensed" in result)) throw new Error("expected condensed result");
|
|
101
|
+
expect(result.text).toContain("llm:a:detail-a");
|
|
102
|
+
expect(result.text).toContain("### Errors");
|
|
103
|
+
expect(result.text).toContain('- missing: thing "missing" not found.');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { lookupToolMeta } from "../lookupMany.js";
|
|
2
|
+
import type { ToolParamDef, ToolSpec } from "../ToolSpec.js";
|
|
3
|
+
import condense from "./condense.js";
|
|
4
|
+
import projectToolParams from "./projectToolParams.js";
|
|
5
|
+
import resolveLookupDetailed from "./resolveLookupDetailed.js";
|
|
6
|
+
import type { LookupStory, LookupStoryView } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compile a lookup story into its MCP `ToolSpec` projection.
|
|
10
|
+
*
|
|
11
|
+
* The tool is named `<noun>_lookup` and takes a required `names` array.
|
|
12
|
+
* Condensed responses render each entity through the story's llm
|
|
13
|
+
* formatter and append a `### Errors` section for failed queries. When
|
|
14
|
+
* the story supports `detailed` (MCP default: true), a false value
|
|
15
|
+
* applies the story's summary projection to each result.
|
|
16
|
+
*/
|
|
17
|
+
export default function compileLookupTool<TDetailed, TFmtInput>(
|
|
18
|
+
story: LookupStory<TDetailed, TFmtInput>,
|
|
19
|
+
): ToolSpec {
|
|
20
|
+
return {
|
|
21
|
+
name: `${story.noun}_lookup`,
|
|
22
|
+
description: story.toolDescription,
|
|
23
|
+
params: buildParams(story),
|
|
24
|
+
readOnly: true,
|
|
25
|
+
async execute(rt, params) {
|
|
26
|
+
const names = (params.names as string[] | undefined) ?? [];
|
|
27
|
+
const view: LookupStoryView = {
|
|
28
|
+
surface: "mcp",
|
|
29
|
+
detailed: resolveLookupDetailed(story, "mcp", params),
|
|
30
|
+
params,
|
|
31
|
+
};
|
|
32
|
+
const result = await story.resolve(rt, names, params);
|
|
33
|
+
|
|
34
|
+
if (params.condensed) {
|
|
35
|
+
const textParts = result.results.map((entity) =>
|
|
36
|
+
story.formatters.llm(story.toFmtInput(entity, view)),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (result.errors.length > 0) {
|
|
40
|
+
textParts.push(
|
|
41
|
+
[
|
|
42
|
+
"### Errors",
|
|
43
|
+
...result.errors.map(
|
|
44
|
+
(error) => `- ${error.query}: ${error.message}`,
|
|
45
|
+
),
|
|
46
|
+
].join("\n"),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return condense(textParts.join("\n\n"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const project = story.project;
|
|
54
|
+
const results =
|
|
55
|
+
!view.detailed && project
|
|
56
|
+
? result.results.map((entity) => project(entity))
|
|
57
|
+
: result.results;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
data: { results, errors: result.errors },
|
|
61
|
+
meta: lookupToolMeta(result),
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildParams<TDetailed, TFmtInput>(
|
|
68
|
+
story: LookupStory<TDetailed, TFmtInput>,
|
|
69
|
+
): Record<string, ToolParamDef> {
|
|
70
|
+
const detailed: Record<string, ToolParamDef> = story.detailedParam
|
|
71
|
+
? {
|
|
72
|
+
detailed: {
|
|
73
|
+
type: "boolean",
|
|
74
|
+
description:
|
|
75
|
+
story.detailedParam.toolDescription ??
|
|
76
|
+
story.detailedParam.description,
|
|
77
|
+
optional: true,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: {};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
names: {
|
|
84
|
+
type: "string[]",
|
|
85
|
+
description: story.namesToolDescription ?? story.namesDescription,
|
|
86
|
+
optional: false,
|
|
87
|
+
},
|
|
88
|
+
...detailed,
|
|
89
|
+
...projectToolParams(story.params ?? []),
|
|
90
|
+
condensed: {
|
|
91
|
+
type: "boolean",
|
|
92
|
+
description: "Token-optimized output",
|
|
93
|
+
optional: true,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PragmaError } from "#error";
|
|
3
|
+
import type { PragmaContext } from "../context.js";
|
|
4
|
+
import compileReadCommand from "./compileReadCommand.js";
|
|
5
|
+
import type { ReadStory } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface FakeData {
|
|
8
|
+
readonly items: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeStory(
|
|
12
|
+
overrides: Partial<ReadStory<FakeData, readonly string[]>> = {},
|
|
13
|
+
): ReadStory<FakeData, readonly string[]> {
|
|
14
|
+
return {
|
|
15
|
+
noun: "thing",
|
|
16
|
+
verb: "list",
|
|
17
|
+
description: "List things",
|
|
18
|
+
toolDescription: "List things over MCP.",
|
|
19
|
+
params: [
|
|
20
|
+
{ name: "flag", type: "boolean", description: "a flag", default: false },
|
|
21
|
+
{ name: "hidden", type: "boolean", description: "mcp", surfaces: "mcp" },
|
|
22
|
+
],
|
|
23
|
+
examples: ["pragma thing list"],
|
|
24
|
+
resolve: async (_rt, params) => ({
|
|
25
|
+
items: params.flag === true ? ["a", "b", "extra"] : ["a", "b"],
|
|
26
|
+
}),
|
|
27
|
+
toOutput: (data) => data.items,
|
|
28
|
+
formatters: {
|
|
29
|
+
plain: (items) => `plain:${items.join(",")}`,
|
|
30
|
+
llm: (items) => `llm:${items.join(",")}`,
|
|
31
|
+
json: (items) => JSON.stringify(items),
|
|
32
|
+
},
|
|
33
|
+
toEnvelope: (data) => ({ data: data.items }),
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeCtx(overrides: Partial<PragmaContext> = {}): PragmaContext {
|
|
39
|
+
return {
|
|
40
|
+
cwd: "/tmp",
|
|
41
|
+
globalFlags: { llm: false, format: "text" as const, verbose: false },
|
|
42
|
+
...overrides,
|
|
43
|
+
} as PragmaContext;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function executeOutput(
|
|
47
|
+
story: ReadStory<FakeData, readonly string[]>,
|
|
48
|
+
params: Record<string, unknown>,
|
|
49
|
+
ctx: PragmaContext,
|
|
50
|
+
): Promise<{ value: unknown; text: string }> {
|
|
51
|
+
const cmd = compileReadCommand(ctx, story);
|
|
52
|
+
const result = await cmd.execute(params, ctx);
|
|
53
|
+
if (result.tag !== "output") throw new Error("expected output result");
|
|
54
|
+
return { value: result.value, text: result.render.plain(result.value) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("compileReadCommand", () => {
|
|
58
|
+
it("projects path, description, examples, and CLI parameters", () => {
|
|
59
|
+
const cmd = compileReadCommand(makeCtx(), makeStory());
|
|
60
|
+
expect(cmd.path).toEqual(["thing", "list"]);
|
|
61
|
+
expect(cmd.description).toBe("List things");
|
|
62
|
+
expect(cmd.meta?.examples).toEqual(["pragma thing list"]);
|
|
63
|
+
expect(cmd.parameters.map((p) => p.name)).toEqual(["flag"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("resolves with parameters and renders the plain formatter", async () => {
|
|
67
|
+
const { value, text } = await executeOutput(
|
|
68
|
+
makeStory(),
|
|
69
|
+
{ flag: true },
|
|
70
|
+
makeCtx(),
|
|
71
|
+
);
|
|
72
|
+
expect(value).toEqual(["a", "b", "extra"]);
|
|
73
|
+
expect(text).toBe("plain:a,b,extra");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("selects the llm formatter under --llm", async () => {
|
|
77
|
+
const ctx = makeCtx({
|
|
78
|
+
globalFlags: { llm: true, format: "text", verbose: false },
|
|
79
|
+
});
|
|
80
|
+
const { text } = await executeOutput(makeStory(), {}, ctx);
|
|
81
|
+
expect(text).toBe("llm:a,b");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("throws the story's guardParams error before resolving", async () => {
|
|
85
|
+
const story = makeStory({
|
|
86
|
+
guardParams: (params) =>
|
|
87
|
+
params.bad === true
|
|
88
|
+
? PragmaError.invalidInput("bad", "true")
|
|
89
|
+
: undefined,
|
|
90
|
+
});
|
|
91
|
+
const cmd = compileReadCommand(makeCtx(), story);
|
|
92
|
+
await expect(cmd.execute({ bad: true }, makeCtx())).rejects.toThrow(
|
|
93
|
+
PragmaError,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws the story's emptyError after resolving", async () => {
|
|
98
|
+
const story = makeStory({
|
|
99
|
+
resolve: async () => ({ items: [] }),
|
|
100
|
+
emptyError: (data) =>
|
|
101
|
+
data.items.length === 0 ? PragmaError.emptyResults("thing") : undefined,
|
|
102
|
+
});
|
|
103
|
+
const cmd = compileReadCommand(makeCtx(), story);
|
|
104
|
+
await expect(cmd.execute({}, makeCtx())).rejects.toThrow(
|
|
105
|
+
"No things found.",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("exposes an ink renderer only when the story defines one", async () => {
|
|
110
|
+
const withInk = makeStory({ renderInk: (items) => `ink:${items.length}` });
|
|
111
|
+
const cmd = compileReadCommand(makeCtx(), withInk);
|
|
112
|
+
const result = await cmd.execute({}, makeCtx());
|
|
113
|
+
if (result.tag !== "output") throw new Error("expected output result");
|
|
114
|
+
expect(result.render.ink?.(["a"])).toBe("ink:1");
|
|
115
|
+
|
|
116
|
+
const plainOnly = compileReadCommand(makeCtx(), makeStory());
|
|
117
|
+
const plainResult = await plainOnly.execute({}, makeCtx());
|
|
118
|
+
if (plainResult.tag !== "output") throw new Error("expected output");
|
|
119
|
+
expect(plainResult.render.ink).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommandDefinition,
|
|
3
|
+
type CommandResult,
|
|
4
|
+
createOutputResult,
|
|
5
|
+
} from "@canonical/cli-core";
|
|
6
|
+
import type { PragmaContext } from "../context.js";
|
|
7
|
+
import { selectFormatter } from "../formatters.js";
|
|
8
|
+
import projectCliParameters from "./projectCliParameters.js";
|
|
9
|
+
import type { ReadStory } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compile a read story into its CLI `CommandDefinition` projection.
|
|
13
|
+
*
|
|
14
|
+
* Owns the shared execute skeleton: guard parameters, resolve, reject
|
|
15
|
+
* empty results when the story defines a guard, then render through the
|
|
16
|
+
* story's formatters (plain/llm/json via global flags) and optional ink
|
|
17
|
+
* view.
|
|
18
|
+
*/
|
|
19
|
+
export default function compileReadCommand<TData, TOutput>(
|
|
20
|
+
ctx: PragmaContext,
|
|
21
|
+
story: ReadStory<TData, TOutput>,
|
|
22
|
+
): CommandDefinition {
|
|
23
|
+
return {
|
|
24
|
+
path: [story.noun, story.verb],
|
|
25
|
+
description: story.description,
|
|
26
|
+
parameters: projectCliParameters(story.params),
|
|
27
|
+
meta: {
|
|
28
|
+
examples: story.examples,
|
|
29
|
+
},
|
|
30
|
+
execute: async (
|
|
31
|
+
params: Record<string, unknown>,
|
|
32
|
+
): Promise<CommandResult> => {
|
|
33
|
+
const guardError = story.guardParams?.(params);
|
|
34
|
+
if (guardError) throw guardError;
|
|
35
|
+
|
|
36
|
+
const data = await story.resolve(ctx, params);
|
|
37
|
+
|
|
38
|
+
const emptyError = story.emptyError?.(data, params);
|
|
39
|
+
if (emptyError) throw emptyError;
|
|
40
|
+
|
|
41
|
+
const renderInk = story.renderInk;
|
|
42
|
+
return createOutputResult(story.toOutput(data, params), {
|
|
43
|
+
plain: selectFormatter(ctx, story.formatters),
|
|
44
|
+
...(renderInk && { ink: (output: TOutput) => renderInk(output) }),
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { PragmaRuntime } from "../types/index.js";
|
|
3
|
+
import compileReadTool from "./compileReadTool.js";
|
|
4
|
+
import type { ReadStory } from "./types.js";
|
|
5
|
+
|
|
6
|
+
interface FakeData {
|
|
7
|
+
readonly items: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const story: ReadStory<FakeData, readonly string[]> = {
|
|
11
|
+
noun: "thing",
|
|
12
|
+
verb: "show",
|
|
13
|
+
description: "Show things",
|
|
14
|
+
toolDescription: "Show things over MCP.",
|
|
15
|
+
params: [
|
|
16
|
+
{ name: "flag", type: "boolean", description: "a flag" },
|
|
17
|
+
{ name: "cliOnly", type: "boolean", description: "cli", surfaces: "cli" },
|
|
18
|
+
],
|
|
19
|
+
examples: [],
|
|
20
|
+
resolve: async (_rt, params) => ({
|
|
21
|
+
items: params.flag === true ? ["a", "b", "extra"] : ["a", "b"],
|
|
22
|
+
}),
|
|
23
|
+
toOutput: (data) => data.items,
|
|
24
|
+
formatters: {
|
|
25
|
+
plain: (items) => `plain:${items.join(",")}`,
|
|
26
|
+
llm: (items) => `llm:${items.join(",")}`,
|
|
27
|
+
json: (items) => JSON.stringify(items),
|
|
28
|
+
},
|
|
29
|
+
toEnvelope: (data) => ({
|
|
30
|
+
data: data.items,
|
|
31
|
+
meta: { count: data.items.length },
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const rt = {} as PragmaRuntime;
|
|
36
|
+
|
|
37
|
+
describe("compileReadTool", () => {
|
|
38
|
+
it("derives the tool name and projects MCP params plus condensed", () => {
|
|
39
|
+
const tool = compileReadTool(story);
|
|
40
|
+
expect(tool.name).toBe("thing_show");
|
|
41
|
+
expect(tool.description).toBe("Show things over MCP.");
|
|
42
|
+
expect(tool.readOnly).toBe(true);
|
|
43
|
+
expect(Object.keys(tool.params ?? {})).toEqual(["flag", "condensed"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns the story envelope for plain calls", async () => {
|
|
47
|
+
const tool = compileReadTool(story);
|
|
48
|
+
const result = await tool.execute(rt, { flag: true });
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
data: ["a", "b", "extra"],
|
|
51
|
+
meta: { count: 3 },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders condensed output through the llm formatter", async () => {
|
|
56
|
+
const tool = compileReadTool(story);
|
|
57
|
+
const result = await tool.execute(rt, { condensed: true });
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
condensed: true,
|
|
60
|
+
text: "llm:a,b",
|
|
61
|
+
tokens: `~${Math.ceil("llm:a,b".length / 4)}`,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ToolSpec } from "../ToolSpec.js";
|
|
2
|
+
import condense from "./condense.js";
|
|
3
|
+
import projectToolParams from "./projectToolParams.js";
|
|
4
|
+
import type { ReadStory } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compile a read story into its MCP `ToolSpec` projection.
|
|
8
|
+
*
|
|
9
|
+
* The tool is named `<noun>_<verb>`. A `condensed` parameter is appended
|
|
10
|
+
* uniformly; condensed responses render through the story's llm formatter,
|
|
11
|
+
* so CLI `--llm` output and MCP condensed output agree by construction.
|
|
12
|
+
*/
|
|
13
|
+
export default function compileReadTool<TData, TOutput>(
|
|
14
|
+
story: ReadStory<TData, TOutput>,
|
|
15
|
+
): ToolSpec {
|
|
16
|
+
return {
|
|
17
|
+
name: `${story.noun}_${story.verb}`,
|
|
18
|
+
description: story.toolDescription,
|
|
19
|
+
params: {
|
|
20
|
+
...projectToolParams(story.params),
|
|
21
|
+
condensed: {
|
|
22
|
+
type: "boolean",
|
|
23
|
+
description: "Token-optimized output",
|
|
24
|
+
optional: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
readOnly: true,
|
|
28
|
+
async execute(rt, params) {
|
|
29
|
+
const data = await story.resolve(rt, params);
|
|
30
|
+
|
|
31
|
+
if (params.condensed) {
|
|
32
|
+
return condense(story.formatters.llm(story.toOutput(data, params)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return story.toEnvelope(data);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import condense from "./condense.js";
|
|
3
|
+
|
|
4
|
+
describe("condense", () => {
|
|
5
|
+
it("wraps text in the condensed envelope with a chars/4 token estimate", () => {
|
|
6
|
+
const result = condense("abcdefgh");
|
|
7
|
+
expect(result).toEqual({
|
|
8
|
+
condensed: true,
|
|
9
|
+
text: "abcdefgh",
|
|
10
|
+
tokens: "~2",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rounds the token estimate up", () => {
|
|
15
|
+
const result = condense("abcde");
|
|
16
|
+
expect(result).toEqual({ condensed: true, text: "abcde", tokens: "~2" });
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ToolResult } from "../ToolSpec.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap condensed Markdown text in the MCP condensed tool envelope.
|
|
5
|
+
*
|
|
6
|
+
* The token figure is the shared chars/4 heuristic — the single estimator
|
|
7
|
+
* for every read tool, replacing the per-spec inline copies.
|
|
8
|
+
*/
|
|
9
|
+
export default function condense(text: string): ToolResult {
|
|
10
|
+
return {
|
|
11
|
+
condensed: true,
|
|
12
|
+
text,
|
|
13
|
+
tokens: `~${Math.ceil(text.length / 4)}`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-story kernel — declare a read story once, project it to CLI and MCP.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { default as compileLookupCommand } from "./compileLookupCommand.js";
|
|
7
|
+
export { default as compileLookupTool } from "./compileLookupTool.js";
|
|
8
|
+
export { default as compileReadCommand } from "./compileReadCommand.js";
|
|
9
|
+
export { default as compileReadTool } from "./compileReadTool.js";
|
|
10
|
+
export { default as condense } from "./condense.js";
|
|
11
|
+
export { default as normalizeNames } from "./normalizeNames.js";
|
|
12
|
+
export { default as requirePragmaContext } from "./requirePragmaContext.js";
|
|
13
|
+
export type {
|
|
14
|
+
LookupStory,
|
|
15
|
+
LookupStoryView,
|
|
16
|
+
ReadStory,
|
|
17
|
+
StoryEnvelope,
|
|
18
|
+
StoryParam,
|
|
19
|
+
StorySurface,
|
|
20
|
+
} from "./types.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import normalizeNames from "./normalizeNames.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeNames", () => {
|
|
5
|
+
it("keeps non-empty strings from an array", () => {
|
|
6
|
+
expect(normalizeNames(["Button", "", "Card", 3, null])).toEqual([
|
|
7
|
+
"Button",
|
|
8
|
+
"Card",
|
|
9
|
+
]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("falls back to the legacy single name", () => {
|
|
13
|
+
expect(normalizeNames(undefined, "Button")).toEqual(["Button"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns empty for missing input", () => {
|
|
17
|
+
expect(normalizeNames(undefined, undefined)).toEqual([]);
|
|
18
|
+
expect(normalizeNames(undefined, "")).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a `names` parameter into a clean string array.
|
|
3
|
+
*
|
|
4
|
+
* Accepts the multiselect array form and the legacy single `name` string,
|
|
5
|
+
* dropping empty and non-string entries. Shared by every lookup story —
|
|
6
|
+
* replaces the per-domain copies that previously lived in each
|
|
7
|
+
* `commands/lookup.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export default function normalizeNames(
|
|
10
|
+
names: unknown,
|
|
11
|
+
legacyName?: unknown,
|
|
12
|
+
): string[] {
|
|
13
|
+
if (Array.isArray(names)) {
|
|
14
|
+
return names.filter(
|
|
15
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (typeof legacyName === "string" && legacyName.length > 0) {
|
|
19
|
+
return [legacyName];
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
}
|