@plainconceptsplatform/workflows 0.1.5 → 0.2.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 +13 -2
- package/dist/catalog-installation.d.ts +8 -0
- package/dist/catalog-installation.js +29 -5
- package/dist/catalog-installation.test.js +101 -13
- package/dist/index.js +14 -1
- package/dist/tui.d.ts +25 -0
- package/dist/tui.js +254 -0
- package/dist/tui.test.d.ts +1 -0
- package/dist/tui.test.js +246 -0
- package/dist/workflow-catalog.d.ts +6 -1
- package/dist/workflow-catalog.js +5 -0
- package/loops/scripts/compile-agent-workflows.mjs +29 -29
- package/loops/workflows/shared/opencode-ci.md +104 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
Install and update shared GitHub Agentic Workflows for Plain Concepts Platform repositories.
|
|
4
4
|
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
The primary entrypoint is the interactive TUI. Run it with no arguments:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @plainconceptsplatform/workflows
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs. Selecting any route installs the full managed catalog plus mandatory `opencode.ci.json` and `scripts/compile-agent-workflows.mjs`. Selecting only templates still installs those mandatory files.
|
|
14
|
+
|
|
5
15
|
## Install
|
|
6
16
|
|
|
7
17
|
Before installing workflows, install and configure [`PlainConceptsPlatform/opencode-onboard`](https://github.com/PlainConceptsPlatform/opencode-onboard) in the consumer repository. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available before compiling workflows.
|
|
8
18
|
|
|
9
|
-
For
|
|
19
|
+
For non-interactive use (advanced):
|
|
10
20
|
|
|
11
21
|
```bash
|
|
12
22
|
npx @plainconceptsplatform/workflows@latest init
|
|
@@ -18,13 +28,14 @@ For a project-local development dependency:
|
|
|
18
28
|
|
|
19
29
|
```bash
|
|
20
30
|
pnpm add -D @plainconceptsplatform/workflows
|
|
31
|
+
pnpm exec workflows # launch interactive TUI
|
|
21
32
|
pnpm exec workflows init
|
|
22
33
|
pnpm exec workflows add
|
|
23
34
|
```
|
|
24
35
|
|
|
25
36
|
`init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
|
|
26
37
|
|
|
27
|
-
`add` installs package-owned files. It stops when a managed file differs. Use `pnpm exec workflows update --force` only when you intend to replace managed workflow files.
|
|
38
|
+
`add` installs package-owned files including the mandatory `opencode.ci.json` and `scripts/compile-agent-workflows.mjs`. It stops when a managed file differs. Use `pnpm exec workflows update --force` only when you intend to replace managed workflow files.
|
|
28
39
|
|
|
29
40
|
Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-maintenance`, `app-ci-dotnet-next`, and `app-ci-node-monorepo`. CI templates are stack-specific copies, not a combined template. Edit their top-level `env:` values for repository paths, package names, and commands.
|
|
30
41
|
|
|
@@ -7,7 +7,15 @@ export interface CatalogInstallOptions {
|
|
|
7
7
|
readonly force?: boolean;
|
|
8
8
|
readonly sourcePath?: string;
|
|
9
9
|
}
|
|
10
|
+
interface CatalogFile {
|
|
11
|
+
readonly source: string;
|
|
12
|
+
readonly target: string;
|
|
13
|
+
readonly managed: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function mandatoryFileSpecs(sourcePath: string): CatalogFile[];
|
|
10
16
|
export declare function catalogSourcePath(modulePath?: string): string;
|
|
11
17
|
export declare function installCatalog(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
12
18
|
export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
19
|
+
export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
13
20
|
export declare function isTemplateName(value: string): value is TemplateName;
|
|
21
|
+
export {};
|
|
@@ -2,33 +2,41 @@ import { access, copyFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { catalogTemplates, templateNames } from "./workflow-catalog.js";
|
|
5
|
+
import { catalogTemplates, mandatoryFiles, templateNames } from "./workflow-catalog.js";
|
|
6
6
|
const sourceMappings = [
|
|
7
7
|
["actions", ".github/actions"],
|
|
8
8
|
["workflows", ".github/workflows"],
|
|
9
9
|
["scripts", "scripts"],
|
|
10
10
|
];
|
|
11
|
+
export function mandatoryFileSpecs(sourcePath) {
|
|
12
|
+
return mandatoryFiles.map((spec) => ({
|
|
13
|
+
source: join(sourcePath, spec.source),
|
|
14
|
+
target: spec.target,
|
|
15
|
+
managed: true,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
11
18
|
export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
12
19
|
return resolve(dirname(modulePath), "..", "loops");
|
|
13
20
|
}
|
|
14
21
|
export async function installCatalog(repositoryPath, options = {}) {
|
|
15
22
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
16
|
-
const files = await catalogFiles(sourcePath);
|
|
17
|
-
const
|
|
23
|
+
const files = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
|
|
24
|
+
const deduplicated = files.filter((file, index) => files.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
|
|
25
|
+
const managedFiles = deduplicated.filter((file) => file.managed);
|
|
18
26
|
const conflicts = (await Promise.all(managedFiles.map(async (file) => {
|
|
19
27
|
const destination = join(repositoryPath, file.target);
|
|
20
28
|
return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
|
|
21
29
|
}))).filter((file) => file !== undefined);
|
|
22
30
|
if (conflicts.length > 0 && !options.force)
|
|
23
31
|
return { installed: [], conflicts };
|
|
24
|
-
await Promise.all(
|
|
32
|
+
await Promise.all(deduplicated.map(async (file) => {
|
|
25
33
|
const destination = join(repositoryPath, file.target);
|
|
26
34
|
if (!file.managed && await exists(destination))
|
|
27
35
|
return;
|
|
28
36
|
await mkdir(dirname(destination), { recursive: true });
|
|
29
37
|
await copyFile(file.source, destination);
|
|
30
38
|
}));
|
|
31
|
-
return { installed:
|
|
39
|
+
return { installed: deduplicated.map((file) => file.target), conflicts };
|
|
32
40
|
}
|
|
33
41
|
export async function installTemplate(repositoryPath, template, options = {}) {
|
|
34
42
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
@@ -43,6 +51,22 @@ export async function installTemplate(repositoryPath, template, options = {}) {
|
|
|
43
51
|
await copyFile(source, destination);
|
|
44
52
|
return { installed: [target], conflicts };
|
|
45
53
|
}
|
|
54
|
+
export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
55
|
+
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
56
|
+
const files = mandatoryFileSpecs(sourcePath).sort((left, right) => left.target.localeCompare(right.target));
|
|
57
|
+
const conflicts = (await Promise.all(files.map(async (file) => {
|
|
58
|
+
const destination = join(repositoryPath, file.target);
|
|
59
|
+
return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
|
|
60
|
+
}))).filter((file) => file !== undefined);
|
|
61
|
+
if (conflicts.length > 0 && !options.force)
|
|
62
|
+
return { installed: [], conflicts };
|
|
63
|
+
await Promise.all(files.map(async (file) => {
|
|
64
|
+
const destination = join(repositoryPath, file.target);
|
|
65
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
66
|
+
await copyFile(file.source, destination);
|
|
67
|
+
}));
|
|
68
|
+
return { installed: files.map((file) => file.target), conflicts };
|
|
69
|
+
}
|
|
46
70
|
export function isTemplateName(value) {
|
|
47
71
|
return templateNames.includes(value);
|
|
48
72
|
}
|
|
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { catalogSourcePath, installCatalog, installTemplate } from "./catalog-installation.js";
|
|
5
|
+
import { catalogSourcePath, installCatalog, installMandatoryFiles, installTemplate } from "./catalog-installation.js";
|
|
6
6
|
const temporaryDirectories = [];
|
|
7
7
|
afterEach(async () => {
|
|
8
8
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
@@ -15,12 +15,14 @@ describe("catalog installation", () => {
|
|
|
15
15
|
});
|
|
16
16
|
expect(catalogSourcePath(join(packageDirectory, "dist", "catalog-installation.js"))).toBe(join(packageDirectory, "loops"));
|
|
17
17
|
});
|
|
18
|
-
it("installs package-owned loops files", async () => {
|
|
18
|
+
it("installs package-owned loops files including mandatory opencode.ci.json and compile script", async () => {
|
|
19
19
|
const sourcePath = await createDirectory({
|
|
20
20
|
"actions/check/action.yml": "name: Check\n",
|
|
21
21
|
"workflows/agent-check.md": "# Check\n",
|
|
22
22
|
"workflows/shared/defaults.md": "defaults\n",
|
|
23
|
-
"scripts/compile.mjs": "console.log('compile');\n",
|
|
23
|
+
"scripts/compile-agent-workflows.mjs": "console.log('compile');\n",
|
|
24
|
+
"scripts/compile.mjs": "console.log('old compile');\n",
|
|
25
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
24
26
|
"workflows/agent-check.lock.yml": "generated\n",
|
|
25
27
|
"actions/actions-lock.json": "generated\n",
|
|
26
28
|
});
|
|
@@ -30,6 +32,8 @@ describe("catalog installation", () => {
|
|
|
30
32
|
".github/actions/check/action.yml",
|
|
31
33
|
".github/workflows/agent-check.md",
|
|
32
34
|
".github/workflows/shared/defaults.md",
|
|
35
|
+
"opencode.ci.json",
|
|
36
|
+
"scripts/compile-agent-workflows.mjs",
|
|
33
37
|
"scripts/compile.mjs",
|
|
34
38
|
],
|
|
35
39
|
conflicts: [],
|
|
@@ -41,7 +45,8 @@ describe("catalog installation", () => {
|
|
|
41
45
|
const sourcePath = await createDirectory({
|
|
42
46
|
"actions/check/action.yml": "# Managed by @plainconceptsplatform/workflows. Source: loops/actions/check/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.\nname: Check\n",
|
|
43
47
|
"workflows/agent-check.md": `---\n${header}# Check\n`,
|
|
44
|
-
"scripts/compile.mjs": "// Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile.mjs. Update with `workflows update --force`; consumer edits may be overwritten.\n",
|
|
48
|
+
"scripts/compile-agent-workflows.mjs": "// Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.\n",
|
|
49
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
45
50
|
"templates/agentics/agentics-checks.yml": `${templateHeader}name: Agentics checks\n`,
|
|
46
51
|
});
|
|
47
52
|
const repositoryPath = await createDirectory({});
|
|
@@ -54,7 +59,8 @@ describe("catalog installation", () => {
|
|
|
54
59
|
const sourcePath = await createDirectory({
|
|
55
60
|
"actions/check/action.yml": "name: Check\n",
|
|
56
61
|
"workflows/agent-check.md": "# Check\n",
|
|
57
|
-
"scripts/compile.mjs": "compile\n",
|
|
62
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
63
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
58
64
|
});
|
|
59
65
|
const repositoryPath = await createDirectory({ ".github/workflows/agent-check.md": "# Check\n" });
|
|
60
66
|
await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toMatchObject({ conflicts: [] });
|
|
@@ -63,7 +69,8 @@ describe("catalog installation", () => {
|
|
|
63
69
|
const sourcePath = await createDirectory({
|
|
64
70
|
"actions/check/action.yml": "name: Check\n",
|
|
65
71
|
"workflows/agent-check.md": "# Check\n",
|
|
66
|
-
"scripts/compile.mjs": "compile\n",
|
|
72
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
73
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
67
74
|
});
|
|
68
75
|
const repositoryPath = await createDirectory({
|
|
69
76
|
".github/workflows/shared/repo-config.md": "legacy consumer config\n",
|
|
@@ -72,7 +79,8 @@ describe("catalog installation", () => {
|
|
|
72
79
|
installed: [
|
|
73
80
|
".github/actions/check/action.yml",
|
|
74
81
|
".github/workflows/agent-check.md",
|
|
75
|
-
"
|
|
82
|
+
"opencode.ci.json",
|
|
83
|
+
"scripts/compile-agent-workflows.mjs",
|
|
76
84
|
],
|
|
77
85
|
conflicts: [],
|
|
78
86
|
});
|
|
@@ -82,26 +90,30 @@ describe("catalog installation", () => {
|
|
|
82
90
|
const sourcePath = await createDirectory({
|
|
83
91
|
"actions/check/action.yml": "package action\n",
|
|
84
92
|
"workflows/agent-check.md": "package workflow\n",
|
|
85
|
-
"scripts/compile.mjs": "package script\n",
|
|
93
|
+
"scripts/compile-agent-workflows.mjs": "package script\n",
|
|
94
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
86
95
|
});
|
|
87
96
|
const repositoryPath = await createDirectory({
|
|
88
97
|
".github/actions/check/action.yml": "consumer action\n",
|
|
89
98
|
".github/workflows/agent-check.md": "consumer workflow\n",
|
|
90
|
-
"scripts/compile.mjs": "consumer script\n",
|
|
99
|
+
"scripts/compile-agent-workflows.mjs": "consumer script\n",
|
|
100
|
+
"opencode.ci.json": "{ \"model\": \"consumer-model\" }\n",
|
|
91
101
|
});
|
|
92
102
|
await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toEqual({
|
|
93
103
|
installed: [],
|
|
94
104
|
conflicts: [
|
|
95
105
|
".github/actions/check/action.yml",
|
|
96
106
|
".github/workflows/agent-check.md",
|
|
97
|
-
"
|
|
107
|
+
"opencode.ci.json",
|
|
108
|
+
"scripts/compile-agent-workflows.mjs",
|
|
98
109
|
],
|
|
99
110
|
});
|
|
100
111
|
await expect(installCatalog(repositoryPath, { force: true, sourcePath })).resolves.toMatchObject({
|
|
101
112
|
conflicts: [
|
|
102
113
|
".github/actions/check/action.yml",
|
|
103
114
|
".github/workflows/agent-check.md",
|
|
104
|
-
"
|
|
115
|
+
"opencode.ci.json",
|
|
116
|
+
"scripts/compile-agent-workflows.mjs",
|
|
105
117
|
],
|
|
106
118
|
});
|
|
107
119
|
await expect(readFile(join(repositoryPath, ".github/actions/check/action.yml"), "utf8")).resolves.toBe("package action\n");
|
|
@@ -110,10 +122,10 @@ describe("catalog installation", () => {
|
|
|
110
122
|
const sourcePath = await createDirectory({
|
|
111
123
|
"actions/check/action.yml": "name: Check\n",
|
|
112
124
|
"workflows/agent-check.md": "# Check\n",
|
|
113
|
-
"scripts/compile.mjs": "compile\n",
|
|
125
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
126
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
114
127
|
"templates/agentics/agentics-checks.yml": "name: Agentics checks\n",
|
|
115
128
|
"templates/ci/app-ci-node-monorepo.yml": "name: Node CI\n",
|
|
116
|
-
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
117
129
|
});
|
|
118
130
|
const repositoryPath = await createDirectory({});
|
|
119
131
|
await installCatalog(repositoryPath, { sourcePath });
|
|
@@ -127,6 +139,82 @@ describe("catalog installation", () => {
|
|
|
127
139
|
conflicts: [],
|
|
128
140
|
});
|
|
129
141
|
});
|
|
142
|
+
it("installCatalog installs mandatory opencode.ci.json and compile script alongside catalog files", async () => {
|
|
143
|
+
const sourcePath = await createDirectory({
|
|
144
|
+
"actions/check/action.yml": "name: Check\n",
|
|
145
|
+
"workflows/agent-check.md": "# Check\n",
|
|
146
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
147
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
148
|
+
});
|
|
149
|
+
const repositoryPath = await createDirectory({});
|
|
150
|
+
const result = await installCatalog(repositoryPath, { sourcePath });
|
|
151
|
+
expect(result.installed).toContain("opencode.ci.json");
|
|
152
|
+
expect(result.installed).toContain("scripts/compile-agent-workflows.mjs");
|
|
153
|
+
await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
|
|
154
|
+
await expect(readFile(join(repositoryPath, "scripts/compile-agent-workflows.mjs"), "utf8")).resolves.toBe("compile\n");
|
|
155
|
+
});
|
|
156
|
+
it("installMandatoryFiles installs opencode.ci.json and compile script without catalog files", async () => {
|
|
157
|
+
const sourcePath = await createDirectory({
|
|
158
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
159
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
160
|
+
});
|
|
161
|
+
const repositoryPath = await createDirectory({});
|
|
162
|
+
const result = await installMandatoryFiles(repositoryPath, { sourcePath });
|
|
163
|
+
expect(result.installed).toEqual(["opencode.ci.json", "scripts/compile-agent-workflows.mjs"]);
|
|
164
|
+
await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
|
|
165
|
+
await expect(readFile(join(repositoryPath, "scripts/compile-agent-workflows.mjs"), "utf8")).resolves.toBe("compile\n");
|
|
166
|
+
});
|
|
167
|
+
it("installMandatoryFiles reports conflicts on differing files", async () => {
|
|
168
|
+
const sourcePath = await createDirectory({
|
|
169
|
+
"scripts/compile-agent-workflows.mjs": "package script\n",
|
|
170
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
171
|
+
});
|
|
172
|
+
const repositoryPath = await createDirectory({
|
|
173
|
+
"opencode.ci.json": "{ \"model\": \"consumer\" }\n",
|
|
174
|
+
"scripts/compile-agent-workflows.mjs": "consumer script\n",
|
|
175
|
+
});
|
|
176
|
+
await expect(installMandatoryFiles(repositoryPath, { sourcePath })).resolves.toEqual({
|
|
177
|
+
installed: [],
|
|
178
|
+
conflicts: ["opencode.ci.json", "scripts/compile-agent-workflows.mjs"],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
it("installMandatoryFiles overwrites with force", async () => {
|
|
182
|
+
const sourcePath = await createDirectory({
|
|
183
|
+
"scripts/compile-agent-workflows.mjs": "package script\n",
|
|
184
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
185
|
+
});
|
|
186
|
+
const repositoryPath = await createDirectory({
|
|
187
|
+
"opencode.ci.json": "{ \"model\": \"consumer\" }\n",
|
|
188
|
+
"scripts/compile-agent-workflows.mjs": "consumer script\n",
|
|
189
|
+
});
|
|
190
|
+
await expect(installMandatoryFiles(repositoryPath, { force: true, sourcePath })).resolves.toMatchObject({
|
|
191
|
+
installed: ["opencode.ci.json", "scripts/compile-agent-workflows.mjs"],
|
|
192
|
+
});
|
|
193
|
+
await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
|
|
194
|
+
});
|
|
195
|
+
it("installMandatoryFiles does not install catalog workflow files", async () => {
|
|
196
|
+
const sourcePath = await createDirectory({
|
|
197
|
+
"actions/check/action.yml": "name: Check\n",
|
|
198
|
+
"workflows/agent-check.md": "# Check\n",
|
|
199
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
200
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
201
|
+
});
|
|
202
|
+
const repositoryPath = await createDirectory({});
|
|
203
|
+
await installMandatoryFiles(repositoryPath, { sourcePath });
|
|
204
|
+
await expect(readFile(join(repositoryPath, ".github/workflows/agent-check.md"), "utf8")).rejects.toThrow();
|
|
205
|
+
});
|
|
206
|
+
it("installCatalog deduplicates compile script in both scripts/ and mandatory files", async () => {
|
|
207
|
+
const sourcePath = await createDirectory({
|
|
208
|
+
"actions/check/action.yml": "name: Check\n",
|
|
209
|
+
"workflows/agent-check.md": "# Check\n",
|
|
210
|
+
"scripts/compile-agent-workflows.mjs": "same compile script\n",
|
|
211
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
212
|
+
});
|
|
213
|
+
const repositoryPath = await createDirectory({});
|
|
214
|
+
const result = await installCatalog(repositoryPath, { sourcePath });
|
|
215
|
+
const compileEntries = result.installed.filter((path) => path === "scripts/compile-agent-workflows.mjs");
|
|
216
|
+
expect(compileEntries).toHaveLength(1);
|
|
217
|
+
});
|
|
130
218
|
it("installs the opencode.ci.json template to the repository root", async () => {
|
|
131
219
|
const sourcePath = await createDirectory({
|
|
132
220
|
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
package/dist/index.js
CHANGED
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
3
|
import { installCatalog, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
4
4
|
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
5
|
+
import { runInteractive } from "./tui.js";
|
|
5
6
|
import { resolve } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
const HELP_TEXT = `Workflows CLI — install and manage Plain Concepts Platform agentic workflows.
|
|
8
9
|
|
|
10
|
+
Run with no arguments to launch the interactive TUI, the primary way to select and install
|
|
11
|
+
workflows and templates:
|
|
12
|
+
|
|
13
|
+
npx @plainconceptsplatform/workflows
|
|
14
|
+
|
|
15
|
+
Advanced (non-interactive) commands:
|
|
16
|
+
|
|
9
17
|
Usage: workflows <command> [options]
|
|
10
18
|
|
|
11
19
|
Commands:
|
|
20
|
+
(default) Launch the interactive TUI for selecting and installing items.
|
|
12
21
|
init Inspect the repository and report its stack and visibility.
|
|
13
22
|
add Install package-owned workflow files into .github/.
|
|
14
23
|
update Alias for add. Use --force to overwrite managed files.
|
|
@@ -29,10 +38,14 @@ Installed workflows are marked [x] when the corresponding .github/workflows/agen
|
|
|
29
38
|
file exists relative to the current directory.`;
|
|
30
39
|
export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
31
40
|
const [command, ...options] = arguments_;
|
|
32
|
-
if (command === "--help" || command === "-h"
|
|
41
|
+
if (command === "--help" || command === "-h") {
|
|
33
42
|
console.log(HELP_TEXT);
|
|
34
43
|
return 0;
|
|
35
44
|
}
|
|
45
|
+
if (command === undefined) {
|
|
46
|
+
const force = options.includes("--force");
|
|
47
|
+
return runInteractive(repositoryPath, { force });
|
|
48
|
+
}
|
|
36
49
|
if (command === "list") {
|
|
37
50
|
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
38
51
|
console.log(formatCatalog(entries));
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type CatalogEntry } from "./catalog-listing.js";
|
|
2
|
+
export type SelectionStatus = "selecting" | "submitting" | "cancelled";
|
|
3
|
+
export interface SelectionState {
|
|
4
|
+
readonly allItems: readonly CatalogEntry[];
|
|
5
|
+
readonly visibleItems: readonly CatalogEntry[];
|
|
6
|
+
readonly selected: ReadonlySet<string>;
|
|
7
|
+
readonly cursor: number;
|
|
8
|
+
readonly filter: string;
|
|
9
|
+
readonly status: SelectionStatus;
|
|
10
|
+
}
|
|
11
|
+
export interface InteractiveOptions {
|
|
12
|
+
readonly force?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function createSelectionState(entries: readonly CatalogEntry[]): SelectionState;
|
|
15
|
+
export declare function fuzzyMatch(text: string, query: string): boolean;
|
|
16
|
+
export declare function filterItems(items: readonly CatalogEntry[], filter: string): readonly CatalogEntry[];
|
|
17
|
+
export declare function applyFilter(state: SelectionState, filter: string): SelectionState;
|
|
18
|
+
export declare function clearFilter(state: SelectionState): SelectionState;
|
|
19
|
+
export declare function moveCursorUp(state: SelectionState): SelectionState;
|
|
20
|
+
export declare function moveCursorDown(state: SelectionState): SelectionState;
|
|
21
|
+
export declare function toggleSelection(state: SelectionState): SelectionState;
|
|
22
|
+
export declare function submitSelection(state: SelectionState): SelectionState;
|
|
23
|
+
export declare function cancelSelection(state: SelectionState): SelectionState;
|
|
24
|
+
export declare function getItemsToInstall(state: SelectionState, force?: boolean): readonly CatalogEntry[];
|
|
25
|
+
export declare function runInteractive(repositoryPath: string, options?: InteractiveOptions): Promise<number>;
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import { formatCatalog, listCatalog } from "./catalog-listing.js";
|
|
3
|
+
import { installCatalog, installMandatoryFiles, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
4
|
+
const ANSI = {
|
|
5
|
+
clear: "\x1b[2J",
|
|
6
|
+
home: "\x1b[H",
|
|
7
|
+
reset: "\x1b[0m",
|
|
8
|
+
bold: "\x1b[1m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
cyan: "\x1b[36m",
|
|
11
|
+
green: "\x1b[32m",
|
|
12
|
+
grey: "\x1b[90m",
|
|
13
|
+
white: "\x1b[37m",
|
|
14
|
+
hideCursor: "\x1b[?25l",
|
|
15
|
+
showCursor: "\x1b[?25h",
|
|
16
|
+
};
|
|
17
|
+
export function createSelectionState(entries) {
|
|
18
|
+
const selected = new Set(entries.filter((entry) => entry.installed).map((entry) => entry.name));
|
|
19
|
+
return {
|
|
20
|
+
allItems: entries,
|
|
21
|
+
visibleItems: entries,
|
|
22
|
+
selected,
|
|
23
|
+
cursor: 0,
|
|
24
|
+
filter: "",
|
|
25
|
+
status: "selecting",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function fuzzyMatch(text, query) {
|
|
29
|
+
if (query === "")
|
|
30
|
+
return true;
|
|
31
|
+
const haystack = text.toLowerCase();
|
|
32
|
+
const needle = query.toLowerCase();
|
|
33
|
+
let needleIndex = 0;
|
|
34
|
+
for (let i = 0; i < haystack.length && needleIndex < needle.length; i++) {
|
|
35
|
+
if (haystack[i] === needle[needleIndex])
|
|
36
|
+
needleIndex++;
|
|
37
|
+
}
|
|
38
|
+
return needleIndex === needle.length;
|
|
39
|
+
}
|
|
40
|
+
export function filterItems(items, filter) {
|
|
41
|
+
if (filter === "")
|
|
42
|
+
return items;
|
|
43
|
+
return items.filter((entry) => fuzzyMatch(`${entry.name} ${entry.description}`, filter));
|
|
44
|
+
}
|
|
45
|
+
export function applyFilter(state, filter) {
|
|
46
|
+
const visibleItems = filterItems(state.allItems, filter);
|
|
47
|
+
const cursor = visibleItems.length === 0 ? 0 : Math.min(state.cursor, visibleItems.length - 1);
|
|
48
|
+
return { ...state, visibleItems, filter, cursor };
|
|
49
|
+
}
|
|
50
|
+
export function clearFilter(state) {
|
|
51
|
+
return applyFilter(state, "");
|
|
52
|
+
}
|
|
53
|
+
export function moveCursorUp(state) {
|
|
54
|
+
if (state.visibleItems.length === 0)
|
|
55
|
+
return state;
|
|
56
|
+
const cursor = state.cursor === 0 ? state.visibleItems.length - 1 : state.cursor - 1;
|
|
57
|
+
return { ...state, cursor };
|
|
58
|
+
}
|
|
59
|
+
export function moveCursorDown(state) {
|
|
60
|
+
if (state.visibleItems.length === 0)
|
|
61
|
+
return state;
|
|
62
|
+
const cursor = state.cursor === state.visibleItems.length - 1 ? 0 : state.cursor + 1;
|
|
63
|
+
return { ...state, cursor };
|
|
64
|
+
}
|
|
65
|
+
export function toggleSelection(state) {
|
|
66
|
+
if (state.visibleItems.length === 0)
|
|
67
|
+
return state;
|
|
68
|
+
const entry = state.visibleItems[state.cursor];
|
|
69
|
+
if (entry === undefined)
|
|
70
|
+
return state;
|
|
71
|
+
const selected = new Set(state.selected);
|
|
72
|
+
if (selected.has(entry.name)) {
|
|
73
|
+
selected.delete(entry.name);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
selected.add(entry.name);
|
|
77
|
+
}
|
|
78
|
+
return { ...state, selected };
|
|
79
|
+
}
|
|
80
|
+
export function submitSelection(state) {
|
|
81
|
+
return { ...state, status: "submitting" };
|
|
82
|
+
}
|
|
83
|
+
export function cancelSelection(state) {
|
|
84
|
+
return { ...state, status: "cancelled" };
|
|
85
|
+
}
|
|
86
|
+
export function getItemsToInstall(state, force = false) {
|
|
87
|
+
return state.allItems.filter((entry) => state.selected.has(entry.name) && (force || !entry.installed));
|
|
88
|
+
}
|
|
89
|
+
function renderEntry(entry, highlighted, checked) {
|
|
90
|
+
const indicator = highlighted ? `${ANSI.bold}${ANSI.white}>${ANSI.reset}` : " ";
|
|
91
|
+
if (checked) {
|
|
92
|
+
const checkbox = `${ANSI.green}[x]${ANSI.reset}`;
|
|
93
|
+
const name = highlighted ? `${ANSI.white}${ANSI.bold}${entry.name}${ANSI.reset}` : entry.name;
|
|
94
|
+
const desc = `${ANSI.dim} — ${entry.description}${ANSI.reset}`;
|
|
95
|
+
return `${indicator} ${checkbox} ${name}${desc}`;
|
|
96
|
+
}
|
|
97
|
+
const checkbox = `${ANSI.grey}[ ]${ANSI.reset}`;
|
|
98
|
+
const name = highlighted ? `${ANSI.white}${ANSI.bold}${entry.name}${ANSI.reset}` : entry.name;
|
|
99
|
+
const desc = `${ANSI.dim} — ${entry.description}${ANSI.reset}`;
|
|
100
|
+
return `${indicator} ${checkbox} ${name}${desc}`;
|
|
101
|
+
}
|
|
102
|
+
function render(state) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
lines.push(`${ANSI.cyan}${ANSI.bold}Plain Concepts Platform — Agentic Workflows${ANSI.reset}`);
|
|
105
|
+
lines.push(`${ANSI.dim}Select items to install. Already-installed items are pre-checked.${ANSI.reset}`);
|
|
106
|
+
lines.push("");
|
|
107
|
+
if (state.filter) {
|
|
108
|
+
lines.push(`${ANSI.cyan}Filter:${ANSI.reset} ${state.filter}${ANSI.grey}█${ANSI.reset}`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
lines.push(`${ANSI.dim}Type to filter, arrows to navigate, space to toggle, Enter to install${ANSI.reset}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push("");
|
|
114
|
+
const routes = state.visibleItems.filter((entry) => entry.kind === "route");
|
|
115
|
+
const templates = state.visibleItems.filter((entry) => entry.kind === "template");
|
|
116
|
+
let index = 0;
|
|
117
|
+
if (routes.length > 0) {
|
|
118
|
+
lines.push(`${ANSI.cyan}Workflows${ANSI.reset}`);
|
|
119
|
+
for (const entry of routes) {
|
|
120
|
+
lines.push(renderEntry(entry, index === state.cursor, state.selected.has(entry.name)));
|
|
121
|
+
index++;
|
|
122
|
+
}
|
|
123
|
+
if (templates.length > 0)
|
|
124
|
+
lines.push("");
|
|
125
|
+
}
|
|
126
|
+
if (templates.length > 0) {
|
|
127
|
+
lines.push(`${ANSI.cyan}Templates${ANSI.reset}`);
|
|
128
|
+
for (const entry of templates) {
|
|
129
|
+
lines.push(renderEntry(entry, index === state.cursor, state.selected.has(entry.name)));
|
|
130
|
+
index++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (state.visibleItems.length === 0) {
|
|
134
|
+
lines.push(`${ANSI.grey} No items match the filter.${ANSI.reset}`);
|
|
135
|
+
}
|
|
136
|
+
lines.push("");
|
|
137
|
+
lines.push(`${ANSI.grey}↑↓ navigate space toggle type to filter Enter install Esc clear filter q quit${ANSI.reset}`);
|
|
138
|
+
process.stdout.write(`${ANSI.clear}${ANSI.home}${lines.join("\n")}\n`);
|
|
139
|
+
}
|
|
140
|
+
export async function runInteractive(repositoryPath, options = {}) {
|
|
141
|
+
const force = options.force ?? false;
|
|
142
|
+
if (process.stdin.isTTY !== true) {
|
|
143
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
144
|
+
console.log(formatCatalog(entries));
|
|
145
|
+
console.log("");
|
|
146
|
+
console.log("Run with a TTY for an interactive selection screen, or use: workflows add [--template <name>] [--force]");
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
150
|
+
let state = createSelectionState(entries);
|
|
151
|
+
readline.emitKeypressEvents(process.stdin);
|
|
152
|
+
process.stdin.setRawMode(true);
|
|
153
|
+
process.stdin.resume();
|
|
154
|
+
process.stdout.write(ANSI.hideCursor);
|
|
155
|
+
render(state);
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
const onKeypress = (str, key) => {
|
|
158
|
+
if (key?.ctrl && key?.name === "c") {
|
|
159
|
+
cleanup();
|
|
160
|
+
resolve(0);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (str === "q" && state.filter === "" && !key?.ctrl && !key?.meta && !key?.shift) {
|
|
164
|
+
cleanup();
|
|
165
|
+
resolve(0);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (key?.name === "escape") {
|
|
169
|
+
state = clearFilter(state);
|
|
170
|
+
render(state);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (key?.name === "up") {
|
|
174
|
+
state = moveCursorUp(state);
|
|
175
|
+
render(state);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key?.name === "down") {
|
|
179
|
+
state = moveCursorDown(state);
|
|
180
|
+
render(state);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key?.name === "space" || str === " ") {
|
|
184
|
+
state = toggleSelection(state);
|
|
185
|
+
render(state);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
189
|
+
cleanup();
|
|
190
|
+
void installSelected(state, repositoryPath, force).then((exitCode) => resolve(exitCode));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (key?.name === "backspace") {
|
|
194
|
+
state = applyFilter(state, state.filter.slice(0, -1));
|
|
195
|
+
render(state);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (str !== undefined &&
|
|
199
|
+
str.length === 1 &&
|
|
200
|
+
str >= " " &&
|
|
201
|
+
str <= "~" &&
|
|
202
|
+
!key?.ctrl &&
|
|
203
|
+
!key?.meta) {
|
|
204
|
+
state = applyFilter(state, state.filter + str);
|
|
205
|
+
render(state);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
function cleanup() {
|
|
209
|
+
process.stdin.setRawMode(false);
|
|
210
|
+
process.stdin.pause();
|
|
211
|
+
process.stdout.write(`${ANSI.showCursor}${ANSI.reset}`);
|
|
212
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
213
|
+
}
|
|
214
|
+
process.stdin.on("keypress", onKeypress);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async function installSelected(state, repositoryPath, force) {
|
|
218
|
+
const items = getItemsToInstall(state, force);
|
|
219
|
+
const routes = items.filter((entry) => entry.kind === "route");
|
|
220
|
+
const templates = items.filter((entry) => entry.kind === "template");
|
|
221
|
+
const allConflicts = [];
|
|
222
|
+
const allInstalled = [];
|
|
223
|
+
if (routes.length > 0) {
|
|
224
|
+
const result = await installCatalog(repositoryPath, { force });
|
|
225
|
+
allConflicts.push(...result.conflicts);
|
|
226
|
+
allInstalled.push(...result.installed);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const result = await installMandatoryFiles(repositoryPath, { force });
|
|
230
|
+
allConflicts.push(...result.conflicts);
|
|
231
|
+
allInstalled.push(...result.installed);
|
|
232
|
+
}
|
|
233
|
+
for (const template of templates) {
|
|
234
|
+
if (!isTemplateName(template.name))
|
|
235
|
+
continue;
|
|
236
|
+
const result = await installTemplate(repositoryPath, template.name, { force });
|
|
237
|
+
allConflicts.push(...result.conflicts);
|
|
238
|
+
allInstalled.push(...result.installed);
|
|
239
|
+
}
|
|
240
|
+
if (allConflicts.length > 0 && !force) {
|
|
241
|
+
console.error(`Conflicts found. Re-run with --force to overwrite:\n${allConflicts.join("\n")}`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
if (allInstalled.length > 0) {
|
|
245
|
+
console.log(`Installed ${allInstalled.length} item(s):`);
|
|
246
|
+
for (const file of allInstalled) {
|
|
247
|
+
console.log(` ${file}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
console.log("All selected items are already installed.");
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tui.test.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applyFilter, cancelSelection, clearFilter, createSelectionState, filterItems, fuzzyMatch, getItemsToInstall, moveCursorDown, moveCursorUp, submitSelection, toggleSelection, } from "./tui.js";
|
|
3
|
+
function makeEntry(name, kind, installed = false) {
|
|
4
|
+
return {
|
|
5
|
+
kind,
|
|
6
|
+
name,
|
|
7
|
+
description: `${name} description`,
|
|
8
|
+
file: `${name}.md`,
|
|
9
|
+
installed,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function makeEntries(installed = []) {
|
|
13
|
+
const routes = ["refine", "implement", "direct", "apply-review", "merge-gate", "audit", "propose"];
|
|
14
|
+
const templates = ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "opencode.ci.json"];
|
|
15
|
+
return [
|
|
16
|
+
...routes.map((name) => makeEntry(name, "route", installed.includes(name))),
|
|
17
|
+
...templates.map((name) => makeEntry(name, "template", installed.includes(name))),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
describe("createSelectionState", () => {
|
|
21
|
+
it("returns all items as visible initially", () => {
|
|
22
|
+
const entries = makeEntries();
|
|
23
|
+
const state = createSelectionState(entries);
|
|
24
|
+
expect(state.allItems).toHaveLength(12);
|
|
25
|
+
expect(state.visibleItems).toHaveLength(12);
|
|
26
|
+
});
|
|
27
|
+
it("pre-selects installed items", () => {
|
|
28
|
+
const entries = makeEntries(["refine", "agentics-checks"]);
|
|
29
|
+
const state = createSelectionState(entries);
|
|
30
|
+
expect(state.selected.has("refine")).toBe(true);
|
|
31
|
+
expect(state.selected.has("agentics-checks")).toBe(true);
|
|
32
|
+
expect(state.selected.has("implement")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it("starts with no filter and cursor at 0", () => {
|
|
35
|
+
const state = createSelectionState(makeEntries());
|
|
36
|
+
expect(state.filter).toBe("");
|
|
37
|
+
expect(state.cursor).toBe(0);
|
|
38
|
+
expect(state.status).toBe("selecting");
|
|
39
|
+
});
|
|
40
|
+
it("pre-selects nothing when nothing is installed", () => {
|
|
41
|
+
const state = createSelectionState(makeEntries());
|
|
42
|
+
expect(state.selected.size).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("fuzzyMatch", () => {
|
|
46
|
+
it("returns true for empty query", () => {
|
|
47
|
+
expect(fuzzyMatch("anything", "")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it("matches exact string", () => {
|
|
50
|
+
expect(fuzzyMatch("refine", "refine")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
it("matches subsequence characters", () => {
|
|
53
|
+
expect(fuzzyMatch("refine", "rfe")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it("matches non-contiguous characters", () => {
|
|
56
|
+
expect(fuzzyMatch("app-ci-dotnet-next", "acdn")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it("is case-insensitive", () => {
|
|
59
|
+
expect(fuzzyMatch("Refine", "REFINE")).toBe(true);
|
|
60
|
+
expect(fuzzyMatch("REFINE", "refine")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it("returns false when characters are not in order", () => {
|
|
63
|
+
expect(fuzzyMatch("refine", "efinr")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
it("returns false when query has characters not in text", () => {
|
|
66
|
+
expect(fuzzyMatch("refine", "xyz")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it("matches against full name + description with combined filter", () => {
|
|
69
|
+
const text = "agentics-checks Agentics checks: verifies generated agent lockfiles";
|
|
70
|
+
expect(fuzzyMatch(text, "acch")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("filterItems", () => {
|
|
74
|
+
const entries = makeEntries();
|
|
75
|
+
it("returns all items when filter is empty", () => {
|
|
76
|
+
expect(filterItems(entries, "")).toHaveLength(12);
|
|
77
|
+
});
|
|
78
|
+
it("filters by name", () => {
|
|
79
|
+
const result = filterItems(entries, "refine");
|
|
80
|
+
expect(result).toHaveLength(1);
|
|
81
|
+
expect(result[0].name).toBe("refine");
|
|
82
|
+
});
|
|
83
|
+
it("filters by description", () => {
|
|
84
|
+
const result = filterItems(entries, "description");
|
|
85
|
+
expect(result).toHaveLength(12);
|
|
86
|
+
});
|
|
87
|
+
it("filters by fuzzy subsequence over name and description", () => {
|
|
88
|
+
const result = filterItems(entries, "acdn");
|
|
89
|
+
const names = result.map((e) => e.name);
|
|
90
|
+
expect(names).toContain("app-ci-dotnet-next");
|
|
91
|
+
});
|
|
92
|
+
it("returns empty when nothing matches", () => {
|
|
93
|
+
expect(filterItems(entries, "zzzzz")).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("applyFilter", () => {
|
|
97
|
+
it("updates the filter and visibleItems", () => {
|
|
98
|
+
const state = createSelectionState(makeEntries());
|
|
99
|
+
const next = applyFilter(state, "refine");
|
|
100
|
+
expect(next.filter).toBe("refine");
|
|
101
|
+
expect(next.visibleItems.length).toBe(1);
|
|
102
|
+
expect(next.visibleItems[0].name).toBe("refine");
|
|
103
|
+
});
|
|
104
|
+
it("clamps cursor when result shrinks", () => {
|
|
105
|
+
const entries = makeEntries();
|
|
106
|
+
let state = createSelectionState(entries);
|
|
107
|
+
state = { ...state, cursor: 10 };
|
|
108
|
+
state = applyFilter(state, "ci");
|
|
109
|
+
expect(state.cursor).toBeLessThanOrEqual(Math.max(0, state.visibleItems.length - 1));
|
|
110
|
+
expect(state.cursor).toBeLessThan(state.visibleItems.length);
|
|
111
|
+
});
|
|
112
|
+
it("sets cursor to 0 when no results match", () => {
|
|
113
|
+
const state = applyFilter(createSelectionState(makeEntries()), "zzz");
|
|
114
|
+
expect(state.visibleItems).toHaveLength(0);
|
|
115
|
+
expect(state.cursor).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
it("preserves selected set when filtering", () => {
|
|
118
|
+
const entries = makeEntries(["refine"]);
|
|
119
|
+
const state = applyFilter(createSelectionState(entries), "ref");
|
|
120
|
+
expect(state.selected.has("refine")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe("clearFilter", () => {
|
|
124
|
+
it("resets filter to empty and restores all items", () => {
|
|
125
|
+
let state = applyFilter(createSelectionState(makeEntries()), "ref");
|
|
126
|
+
state = clearFilter(state);
|
|
127
|
+
expect(state.filter).toBe("");
|
|
128
|
+
expect(state.visibleItems).toHaveLength(12);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe("moveCursorUp / moveCursorDown", () => {
|
|
132
|
+
it("moves cursor down by one", () => {
|
|
133
|
+
const state = createSelectionState(makeEntries());
|
|
134
|
+
const next = moveCursorDown(state);
|
|
135
|
+
expect(next.cursor).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
it("wraps cursor to top from bottom", () => {
|
|
138
|
+
const entries = makeEntries();
|
|
139
|
+
let state = createSelectionState(entries);
|
|
140
|
+
state = { ...state, cursor: entries.length - 1 };
|
|
141
|
+
state = moveCursorDown(state);
|
|
142
|
+
expect(state.cursor).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
it("moves cursor up by one", () => {
|
|
145
|
+
const entries = makeEntries();
|
|
146
|
+
let state = createSelectionState(entries);
|
|
147
|
+
state = { ...state, cursor: 3 };
|
|
148
|
+
state = moveCursorUp(state);
|
|
149
|
+
expect(state.cursor).toBe(2);
|
|
150
|
+
});
|
|
151
|
+
it("wraps cursor to bottom from top", () => {
|
|
152
|
+
const state = moveCursorUp(createSelectionState(makeEntries()));
|
|
153
|
+
expect(state.cursor).toBe(11);
|
|
154
|
+
});
|
|
155
|
+
it("does not move when list is empty", () => {
|
|
156
|
+
const entries = [];
|
|
157
|
+
const state = createSelectionState(entries);
|
|
158
|
+
expect(moveCursorUp(state).cursor).toBe(0);
|
|
159
|
+
expect(moveCursorDown(state).cursor).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
it("navigates within filtered results", () => {
|
|
162
|
+
let state = createSelectionState(makeEntries());
|
|
163
|
+
state = applyFilter(state, "ci");
|
|
164
|
+
state = moveCursorDown(state);
|
|
165
|
+
expect(state.cursor).toBe(1);
|
|
166
|
+
expect(state.cursor).toBeLessThan(state.visibleItems.length);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("toggleSelection", () => {
|
|
170
|
+
it("selects an unselected item at cursor", () => {
|
|
171
|
+
const state = createSelectionState(makeEntries());
|
|
172
|
+
expect(state.selected.has("refine")).toBe(false);
|
|
173
|
+
const next = toggleSelection(state);
|
|
174
|
+
expect(next.selected.has("refine")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
it("deselects a selected item at cursor", () => {
|
|
177
|
+
const entries = makeEntries(["refine"]);
|
|
178
|
+
const state = createSelectionState(entries);
|
|
179
|
+
expect(state.selected.has("refine")).toBe(true);
|
|
180
|
+
const next = toggleSelection(state);
|
|
181
|
+
expect(next.selected.has("refine")).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
it("toggles the item at cursor position within filtered list", () => {
|
|
184
|
+
let state = createSelectionState(makeEntries());
|
|
185
|
+
state = applyFilter(state, "ci");
|
|
186
|
+
state = moveCursorDown(state);
|
|
187
|
+
const entryAtCursor = state.visibleItems[state.cursor];
|
|
188
|
+
const next = toggleSelection(state);
|
|
189
|
+
expect(next.selected.has(entryAtCursor.name)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
it("does nothing when list is empty", () => {
|
|
192
|
+
const state = createSelectionState([]);
|
|
193
|
+
const next = toggleSelection(state);
|
|
194
|
+
expect(next.selected.size).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
it("does not change other selections", () => {
|
|
197
|
+
const entries = makeEntries(["refine", "audit"]);
|
|
198
|
+
const state = createSelectionState(entries);
|
|
199
|
+
const next = toggleSelection(state);
|
|
200
|
+
expect(next.selected.has("refine")).toBe(false);
|
|
201
|
+
expect(next.selected.has("audit")).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe("submitSelection / cancelSelection", () => {
|
|
205
|
+
it("sets status to submitting", () => {
|
|
206
|
+
const state = submitSelection(createSelectionState(makeEntries()));
|
|
207
|
+
expect(state.status).toBe("submitting");
|
|
208
|
+
});
|
|
209
|
+
it("sets status to cancelled", () => {
|
|
210
|
+
const state = cancelSelection(createSelectionState(makeEntries()));
|
|
211
|
+
expect(state.status).toBe("cancelled");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe("getItemsToInstall", () => {
|
|
215
|
+
it("returns empty when nothing is selected", () => {
|
|
216
|
+
const state = createSelectionState(makeEntries());
|
|
217
|
+
expect(getItemsToInstall(state)).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
it("returns only selected items that are not already installed", () => {
|
|
220
|
+
const entries = makeEntries(["refine"]);
|
|
221
|
+
let state = createSelectionState(entries);
|
|
222
|
+
state = { ...state, cursor: 1 };
|
|
223
|
+
state = toggleSelection(state);
|
|
224
|
+
const items = getItemsToInstall(state);
|
|
225
|
+
const names = items.map((e) => e.name);
|
|
226
|
+
expect(names).toContain("implement");
|
|
227
|
+
expect(names).not.toContain("refine");
|
|
228
|
+
});
|
|
229
|
+
it("returns already-installed items when force is true", () => {
|
|
230
|
+
const entries = makeEntries(["refine"]);
|
|
231
|
+
let state = createSelectionState(entries);
|
|
232
|
+
const items = getItemsToInstall(state, true);
|
|
233
|
+
const names = items.map((e) => e.name);
|
|
234
|
+
expect(names).toContain("refine");
|
|
235
|
+
});
|
|
236
|
+
it("includes both routes and templates", () => {
|
|
237
|
+
let state = createSelectionState(makeEntries());
|
|
238
|
+
state = toggleSelection(state);
|
|
239
|
+
state = { ...state, cursor: 7 };
|
|
240
|
+
state = toggleSelection(state);
|
|
241
|
+
const items = getItemsToInstall(state);
|
|
242
|
+
const kinds = items.map((e) => e.kind);
|
|
243
|
+
expect(kinds).toContain("route");
|
|
244
|
+
expect(kinds).toContain("template");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -7,7 +7,12 @@ export interface WorkflowRoute {
|
|
|
7
7
|
readonly defaultEnabled: boolean;
|
|
8
8
|
}
|
|
9
9
|
export declare const workflowRoutes: readonly WorkflowRoute[];
|
|
10
|
-
export declare const packageOwnedTargets: readonly [".github/actions", ".github/workflows/agent-*.md", ".github/workflows/shared/platform-defaults.md", ".github/workflows/shared/opencode-ci.md", ".github/workflows/work-router.yml", "scripts/compile-agent-workflows.mjs"];
|
|
10
|
+
export declare const packageOwnedTargets: readonly [".github/actions", ".github/workflows/agent-*.md", ".github/workflows/shared/platform-defaults.md", ".github/workflows/shared/opencode-ci.md", ".github/workflows/work-router.yml", "scripts/compile-agent-workflows.mjs", "opencode.ci.json"];
|
|
11
|
+
export interface MandatoryFile {
|
|
12
|
+
readonly source: string;
|
|
13
|
+
readonly target: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const mandatoryFiles: readonly MandatoryFile[];
|
|
11
16
|
export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
|
|
12
17
|
export declare const templateNames: readonly ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "opencode.ci.json"];
|
|
13
18
|
export type TemplateName = (typeof templateNames)[number];
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -23,6 +23,11 @@ export const packageOwnedTargets = [
|
|
|
23
23
|
".github/workflows/shared/opencode-ci.md",
|
|
24
24
|
".github/workflows/work-router.yml",
|
|
25
25
|
"scripts/compile-agent-workflows.mjs",
|
|
26
|
+
"opencode.ci.json",
|
|
27
|
+
];
|
|
28
|
+
export const mandatoryFiles = [
|
|
29
|
+
{ source: "templates/opencode/opencode.ci.json", target: "opencode.ci.json" },
|
|
30
|
+
{ source: "scripts/compile-agent-workflows.mjs", target: "scripts/compile-agent-workflows.mjs" },
|
|
26
31
|
];
|
|
27
32
|
export const generatedConsumerTargets = [
|
|
28
33
|
".github/workflows/agent-*.lock.yml",
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
// Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
|
|
5
|
-
const workflowDirectory = existsSync("loops/workflows") ? "loops/workflows" : ".github/workflows";
|
|
6
|
-
|
|
7
|
-
// On Windows, `gh` resolves to a shim that spawnSync cannot find without a shell.
|
|
8
|
-
// Resolve the full path via `where` so spawnSync works with shell: false (security-safe).
|
|
9
|
-
function resolveGhPath() {
|
|
10
|
-
if (process.platform !== "win32") return "gh";
|
|
11
|
-
const result = spawnSync("where", ["gh"], { encoding: "utf8", shell: false });
|
|
12
|
-
if (result.status === 0) {
|
|
13
|
-
const first = result.stdout.split("\n").map((s) => s.trim()).find(Boolean);
|
|
14
|
-
if (first) return first;
|
|
15
|
-
}
|
|
16
|
-
return "gh";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir", workflowDirectory], {
|
|
20
|
-
stdio: "inherit",
|
|
21
|
-
shell: false,
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
if (compile.error?.code === "ENOENT" || compile.status === null) {
|
|
25
|
-
process.stderr.write("Could not run `gh aw compile`. Install githubnext/gh-aw first.\n");
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
process.exit(compile.status ?? 1);
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const workflowDirectory = existsSync("loops/workflows") ? "loops/workflows" : ".github/workflows";
|
|
6
|
+
|
|
7
|
+
// On Windows, `gh` resolves to a shim that spawnSync cannot find without a shell.
|
|
8
|
+
// Resolve the full path via `where` so spawnSync works with shell: false (security-safe).
|
|
9
|
+
function resolveGhPath() {
|
|
10
|
+
if (process.platform !== "win32") return "gh";
|
|
11
|
+
const result = spawnSync("where", ["gh"], { encoding: "utf8", shell: false });
|
|
12
|
+
if (result.status === 0) {
|
|
13
|
+
const first = result.stdout.split("\n").map((s) => s.trim()).find(Boolean);
|
|
14
|
+
if (first) return first;
|
|
15
|
+
}
|
|
16
|
+
return "gh";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir", workflowDirectory], {
|
|
20
|
+
stdio: "inherit",
|
|
21
|
+
shell: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (compile.error?.code === "ENOENT" || compile.status === null) {
|
|
25
|
+
process.stderr.write("Could not run `gh aw compile`. Install githubnext/gh-aw first.\n");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
process.exit(compile.status ?? 1);
|
|
@@ -5,23 +5,126 @@ env:
|
|
|
5
5
|
CODEGRAPH_VERSION: "1.5.0"
|
|
6
6
|
RTK_VERSION: "0.44.1"
|
|
7
7
|
RTK_SHA256: "986f29704469b3d1051e2474105c6c75ab8b73651068dcd61612c1fb3938ad95"
|
|
8
|
-
description:
|
|
8
|
+
description: |
|
|
9
|
+
Shared CI setup for Platform agent workflows. Installs pinned tooling and merges
|
|
10
|
+
opencode.ci.json into opencode.jsonc so the CI agent gets its provider and model config.
|
|
11
|
+
|
|
12
|
+
Consumer-specific steps (NuGet, .NET restore, OpenSpec, etc.) should be added after the
|
|
13
|
+
shared baseline in the consumer copy. The merge step below is package-owned and required
|
|
14
|
+
for the agent to resolve the `plainconcepts` provider and its models.
|
|
9
15
|
|
|
10
16
|
pre-agent-steps:
|
|
11
17
|
- name: Create agent scratch directory
|
|
12
18
|
run: mkdir -p .opencode/.tmp
|
|
19
|
+
|
|
20
|
+
- name: Install ripgrep
|
|
21
|
+
run: |
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
if ! command -v rg > /dev/null; then
|
|
25
|
+
sudo apt-get update
|
|
26
|
+
sudo apt-get install --yes ripgrep
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
rg --version
|
|
30
|
+
|
|
13
31
|
- name: Activate the pnpm version package.json pins
|
|
14
32
|
run: |
|
|
15
33
|
set -euo pipefail
|
|
16
34
|
corepack enable
|
|
17
35
|
corepack prepare --activate
|
|
18
36
|
pnpm --version
|
|
37
|
+
|
|
19
38
|
- name: Cache the pnpm store
|
|
20
39
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
21
40
|
with:
|
|
22
41
|
path: ~/.local/share/pnpm/store
|
|
23
42
|
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
24
43
|
restore-keys: pnpm-store-${{ runner.os }}-
|
|
44
|
+
|
|
45
|
+
- name: Install RTK
|
|
46
|
+
run: |
|
|
47
|
+
set -euo pipefail
|
|
48
|
+
|
|
49
|
+
tarball="$RUNNER_TEMP/rtk.tar.gz"
|
|
50
|
+
curl -fsSL -o "$tarball" \
|
|
51
|
+
"https://github.com/rtk-ai/rtk/releases/download/v${RTK_VERSION}/rtk-x86_64-unknown-linux-musl.tar.gz"
|
|
52
|
+
echo "${RTK_SHA256} $tarball" | sha256sum --check --strict
|
|
53
|
+
|
|
54
|
+
tar -xzf "$tarball" -C "$RUNNER_TEMP"
|
|
55
|
+
sudo install -m 0755 "$RUNNER_TEMP/rtk" /usr/local/bin/rtk
|
|
56
|
+
|
|
57
|
+
rtk --version
|
|
58
|
+
rtk init -g --opencode --auto-patch
|
|
59
|
+
|
|
60
|
+
- name: Install agentmemory
|
|
61
|
+
run: |
|
|
62
|
+
set -euo pipefail
|
|
63
|
+
npm install -g "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}"
|
|
64
|
+
agentmemory --version
|
|
65
|
+
|
|
66
|
+
- name: Install codegraph and index the repository
|
|
67
|
+
continue-on-error: true
|
|
68
|
+
run: |
|
|
69
|
+
set -euo pipefail
|
|
70
|
+
npm install -g "@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
|
|
71
|
+
codegraph init
|
|
72
|
+
|
|
73
|
+
- name: Install opencode plugin dependencies
|
|
74
|
+
run: |
|
|
75
|
+
set -euo pipefail
|
|
76
|
+
|
|
77
|
+
if [ ! -f .opencode/package.json ]; then
|
|
78
|
+
echo "No .opencode/package.json, nothing to install"
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# These plugins are optional tooling for the agent, not something the task
|
|
83
|
+
# depends on, so a transitive peer conflict between two of them must not
|
|
84
|
+
# take down every audit, propose and implement run. Strict first, so a real
|
|
85
|
+
# incompatibility is still visible in the log.
|
|
86
|
+
if ! npm install --prefix .opencode; then
|
|
87
|
+
echo "::warning::Strict npm install failed on a peer conflict. Retrying with --legacy-peer-deps; check .opencode/package.json."
|
|
88
|
+
npm install --prefix .opencode --legacy-peer-deps
|
|
89
|
+
fi
|
|
90
|
+
|
|
25
91
|
- name: Install workspace dependencies
|
|
26
92
|
run: pnpm install --frozen-lockfile
|
|
93
|
+
|
|
94
|
+
- name: Merge the CI-only OpenCode provider into opencode.jsonc
|
|
95
|
+
run: |
|
|
96
|
+
set -euo pipefail
|
|
97
|
+
|
|
98
|
+
CONFIG=opencode.jsonc
|
|
99
|
+
FRAGMENT=opencode.ci.json
|
|
100
|
+
|
|
101
|
+
[ -f "$FRAGMENT" ] || { echo "::error::$FRAGMENT is missing from the checkout"; exit 1; }
|
|
102
|
+
|
|
103
|
+
# Pure JSON on purpose, not JSONC: jq cannot parse `//` comments, and a naive
|
|
104
|
+
# comment-stripper would corrupt the `http://` inside the provider's api URL.
|
|
105
|
+
jq -e . "$FRAGMENT" > /dev/null \
|
|
106
|
+
|| { echo "::error::$FRAGMENT is not valid JSON. Comments are not allowed in it."; exit 1; }
|
|
107
|
+
|
|
108
|
+
# Despite the .jsonc name, this file is committed in this repository and is read by
|
|
109
|
+
# jq below, so it must contain no comments. A single `//` line fails the merge with
|
|
110
|
+
# "Invalid numeric literal", which names neither the file nor the reason.
|
|
111
|
+
if [ -f "$CONFIG" ] && ! jq -e . "$CONFIG" > /dev/null 2>&1; then
|
|
112
|
+
echo "::error::$CONFIG is tracked and must be comment-free JSON: jq cannot parse it."
|
|
113
|
+
exit 1
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# opencode.jsonc is untracked in most repositories, so it usually does not exist here.
|
|
117
|
+
# Create it from the fragment when absent, merge when a checkout did provide one.
|
|
118
|
+
if [ -f "$CONFIG" ]; then
|
|
119
|
+
merged=$(jq -s '.[0] * .[1]' "$CONFIG" "$FRAGMENT")
|
|
120
|
+
else
|
|
121
|
+
merged=$(jq -S . "$FRAGMENT")
|
|
122
|
+
fi
|
|
123
|
+
printf '%s\n' "$merged" > "$CONFIG"
|
|
124
|
+
|
|
125
|
+
# gh-aw's own "Write OpenCode Config" step runs next and merges its base config with
|
|
126
|
+
# `$existing * $base`. Base wins on conflicting keys, but it defines neither `model`
|
|
127
|
+
# nor this provider, so both survive and `awf-proxy` is added alongside.
|
|
128
|
+
echo "Wrote $CONFIG from $FRAGMENT:"
|
|
129
|
+
jq -r '" model: \(.model // "unset")", " plugins: \(.plugin // [] | join(", "))", " providers: \(.provider // {} | keys | join(", "))"' "$CONFIG"
|
|
27
130
|
---
|