@plainconceptsplatform/workflows 0.1.2 → 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 +40 -11
- package/dist/catalog-installation.d.ts +11 -2
- package/dist/catalog-installation.js +53 -9
- package/dist/catalog-installation.test.js +198 -16
- package/dist/catalog-listing.d.ts +13 -0
- package/dist/catalog-listing.js +68 -0
- package/dist/catalog-listing.test.d.ts +1 -0
- package/dist/catalog-listing.test.js +137 -0
- package/dist/index.js +76 -12
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +112 -0
- package/dist/repository-inspection.test.d.ts +1 -0
- package/dist/repository-inspection.test.js +77 -0
- 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 +15 -8
- package/dist/workflow-catalog.js +25 -15
- package/dist/workflow-catalog.test.js +18 -3
- package/loops/actions/add-issue-labels/action.yml +30 -29
- package/loops/actions/agent-output.cjs +1 -0
- package/loops/actions/apply-agent-bundle/action.yml +24 -23
- package/loops/actions/apply-agent-bundle/apply-bundle.sh +1 -0
- package/loops/actions/apply-agent-comments/action.yml +42 -41
- package/loops/actions/apply-agent-labels/action.yml +55 -54
- package/loops/actions/apply-agent-output/action.yml +108 -107
- package/loops/actions/audit-close/action.yml +104 -103
- package/loops/actions/classify-route/action.yml +91 -90
- package/loops/actions/classify-route/classify-route.sh +1 -0
- package/loops/actions/cleanup-artifacts/action.yml +65 -64
- package/loops/actions/close-agent-issues/action.yml +43 -42
- package/loops/actions/create-agent-issues/action.yml +52 -51
- package/loops/actions/create-issue-comment/action.yml +29 -28
- package/loops/actions/download-agent-output/action.yml +53 -52
- package/loops/actions/identify-gate-subject/action.yml +97 -96
- package/loops/actions/link-pr-to-issue/action.yml +40 -39
- package/loops/actions/list-open-issues/action.yml +33 -32
- package/loops/actions/load-issue-context/action.yml +43 -42
- package/loops/actions/merge-agent-pr/action.yml +36 -35
- package/loops/actions/push-agent-branch/action.yml +45 -44
- package/loops/actions/remove-issue-labels/action.yml +35 -34
- package/loops/actions/stale-recovery/action.yml +288 -287
- package/loops/actions/update-agent-issues/action.yml +58 -57
- package/loops/actions/validate-refine-output/action.yml +44 -43
- package/loops/actions/validate-refine-output/validate-refine-output.sh +1 -0
- package/loops/actions/verify-composite-actions/action.yml +9 -8
- package/loops/actions/verify-composite-actions/verify-composite-actions.sh +1 -0
- package/loops/actions/verify-refine-output/action.yml +9 -8
- package/loops/actions/verify-refine-output/verify-refine-output.sh +1 -0
- package/loops/actions/verify-route-matrix/action.yml +9 -8
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1 -0
- package/loops/scripts/compile-agent-workflows.mjs +29 -13
- package/loops/templates/agentics/agentics-checks.yml +86 -0
- package/loops/templates/agentics/agentics-maintenance.yml +121 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +167 -0
- package/loops/templates/ci/app-ci-node-monorepo.yml +178 -0
- package/loops/templates/opencode/opencode.ci.json +46 -0
- package/loops/templates/opencode/opencode.ci.json.md +41 -0
- package/loops/workflows/agent-apply-review.md +9 -10
- package/loops/workflows/agent-audit.md +6 -7
- package/loops/workflows/agent-direct.md +7 -8
- package/loops/workflows/agent-implement.md +7 -8
- package/loops/workflows/agent-merge-gate.md +9 -10
- package/loops/workflows/agent-propose.md +7 -8
- package/loops/workflows/agent-refine.md +5 -7
- package/loops/workflows/shared/opencode-ci.md +105 -1
- package/loops/workflows/shared/platform-defaults.md +1 -0
- package/loops/workflows/work-router.yml +1 -0
- package/package.json +2 -2
- package/loops/aw/repo-config.md +0 -12
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
6
|
+
import { workflowRoutes, catalogTemplates } from "./workflow-catalog.js";
|
|
7
|
+
const temporaryDirectories = [];
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
10
|
+
});
|
|
11
|
+
describe("catalog listing", () => {
|
|
12
|
+
it("returns all routes and templates", async () => {
|
|
13
|
+
const repositoryPath = await createRepository({});
|
|
14
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
15
|
+
expect(entries).toHaveLength(workflowRoutes.length + catalogTemplates.length);
|
|
16
|
+
const routeNames = entries.filter((entry) => entry.kind === "route").map((entry) => entry.name);
|
|
17
|
+
const templateNames = entries.filter((entry) => entry.kind === "template").map((entry) => entry.name);
|
|
18
|
+
expect(routeNames).toEqual(["refine", "implement", "direct", "apply-review", "merge-gate", "audit", "propose"]);
|
|
19
|
+
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "opencode.ci.json"]);
|
|
20
|
+
});
|
|
21
|
+
it("reports all entries as not installed in an empty repository", async () => {
|
|
22
|
+
const repositoryPath = await createRepository({});
|
|
23
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
24
|
+
expect(entries.every((entry) => entry.installed === false)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it("marks a route as installed when its worker file exists", async () => {
|
|
27
|
+
const repositoryPath = await createRepository({
|
|
28
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
29
|
+
});
|
|
30
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
31
|
+
const refineEntry = entries.find((entry) => entry.name === "refine");
|
|
32
|
+
expect(refineEntry).toBeDefined();
|
|
33
|
+
expect(refineEntry.installed).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it("marks a template as installed when its template file exists", async () => {
|
|
36
|
+
const repositoryPath = await createRepository({
|
|
37
|
+
".github/workflows/agentics-checks.yml": "name: Agentics checks",
|
|
38
|
+
});
|
|
39
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
40
|
+
const checksEntry = entries.find((entry) => entry.name === "agentics-checks");
|
|
41
|
+
expect(checksEntry).toBeDefined();
|
|
42
|
+
expect(checksEntry.installed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it("marks the opencode.ci.json template as installed when the file exists at repository root", async () => {
|
|
45
|
+
const repositoryPath = await createRepository({
|
|
46
|
+
"opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }",
|
|
47
|
+
});
|
|
48
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
49
|
+
const opencodeEntry = entries.find((entry) => entry.name === "opencode.ci.json");
|
|
50
|
+
expect(opencodeEntry).toBeDefined();
|
|
51
|
+
expect(opencodeEntry.installed).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it("reports mixed installed and uninstalled entries", async () => {
|
|
54
|
+
const repositoryPath = await createRepository({
|
|
55
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
56
|
+
".github/workflows/agent-audit.md": "# Audit",
|
|
57
|
+
".github/workflows/app-ci-dotnet-next.yml": "name: CI",
|
|
58
|
+
"opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }",
|
|
59
|
+
});
|
|
60
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
61
|
+
expect(entries.filter((entry) => entry.installed).map((entry) => entry.name))
|
|
62
|
+
.toEqual(expect.arrayContaining(["refine", "audit", "app-ci-dotnet-next", "opencode.ci.json"]));
|
|
63
|
+
expect(entries.filter((entry) => !entry.installed)).toHaveLength(workflowRoutes.length + catalogTemplates.length - 4);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("searchCatalog", () => {
|
|
67
|
+
const sampleEntries = [
|
|
68
|
+
{ kind: "route", name: "refine", description: "Refines an issue into a user story.", file: "agent-refine.md", installed: false },
|
|
69
|
+
{ kind: "route", name: "audit", description: "Read-only repository audit.", file: "agent-audit.md", installed: true },
|
|
70
|
+
{ kind: "template", name: "app-ci-dotnet-next", description: "App CI pipeline for .NET + Next.js.", file: "app-ci-dotnet-next.yml", installed: false },
|
|
71
|
+
];
|
|
72
|
+
it("filters by name match", () => {
|
|
73
|
+
const results = searchCatalog(sampleEntries, "audit");
|
|
74
|
+
expect(results).toHaveLength(1);
|
|
75
|
+
expect(results[0].name).toBe("audit");
|
|
76
|
+
});
|
|
77
|
+
it("filters by description match", () => {
|
|
78
|
+
const results = searchCatalog(sampleEntries, "user story");
|
|
79
|
+
expect(results).toHaveLength(1);
|
|
80
|
+
expect(results[0].name).toBe("refine");
|
|
81
|
+
});
|
|
82
|
+
it("is case-insensitive", () => {
|
|
83
|
+
const results = searchCatalog(sampleEntries, "AUDIT");
|
|
84
|
+
expect(results).toHaveLength(1);
|
|
85
|
+
expect(results[0].name).toBe("audit");
|
|
86
|
+
});
|
|
87
|
+
it("returns empty array for no matches", () => {
|
|
88
|
+
const results = searchCatalog(sampleEntries, "nonexistent");
|
|
89
|
+
expect(results).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
it("returns empty array for empty query after trim", () => {
|
|
92
|
+
expect(searchCatalog(sampleEntries, " ")).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
it("returns both routes and templates when the query matches both kinds", () => {
|
|
95
|
+
const entries = [
|
|
96
|
+
{ kind: "route", name: "direct", description: "Executes a CI instruction.", file: "agent-direct.md", installed: false },
|
|
97
|
+
{ kind: "template", name: "app-ci-dotnet-next", description: "App CI pipeline.", file: "app-ci-dotnet-next.yml", installed: true },
|
|
98
|
+
];
|
|
99
|
+
const results = searchCatalog(entries, "CI");
|
|
100
|
+
expect(results).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("formatCatalog", () => {
|
|
104
|
+
it("groups routes under Workflows and templates under Templates", () => {
|
|
105
|
+
const entries = [
|
|
106
|
+
{ kind: "route", name: "refine", description: "Refines a story.", file: "agent-refine.md", installed: true },
|
|
107
|
+
{ kind: "template", name: "app-ci-dotnet-next", description: "CI for .NET.", file: "app-ci-dotnet-next.yml", installed: false },
|
|
108
|
+
];
|
|
109
|
+
const output = formatCatalog(entries);
|
|
110
|
+
expect(output).toContain("Workflows:");
|
|
111
|
+
expect(output).toContain("Templates:");
|
|
112
|
+
expect(output).toContain("[x] refine");
|
|
113
|
+
expect(output).toContain("[ ] app-ci-dotnet-next");
|
|
114
|
+
});
|
|
115
|
+
it("prints a no-match message for an empty result", () => {
|
|
116
|
+
expect(formatCatalog([])).toBe("No workflows matched the search query.");
|
|
117
|
+
});
|
|
118
|
+
it("prints only a Workflows section when no templates match", () => {
|
|
119
|
+
const entries = [
|
|
120
|
+
{ kind: "route", name: "refine", description: "Refines.", file: "agent-refine.md", installed: false },
|
|
121
|
+
];
|
|
122
|
+
const output = formatCatalog(entries);
|
|
123
|
+
expect(output).toContain("Workflows:");
|
|
124
|
+
expect(output).not.toContain("Templates:");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
async function createRepository(files) {
|
|
128
|
+
const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
|
|
129
|
+
temporaryDirectories.push(repositoryPath);
|
|
130
|
+
await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
|
|
131
|
+
const path = join(repositoryPath, relativePath);
|
|
132
|
+
const { mkdir } = await import("node:fs/promises");
|
|
133
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
134
|
+
await writeFile(path, content, "utf8");
|
|
135
|
+
}));
|
|
136
|
+
return repositoryPath;
|
|
137
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
|
-
import { installCatalog } from "./catalog-installation.js";
|
|
4
|
-
import {
|
|
3
|
+
import { installCatalog, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
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";
|
|
8
|
+
const HELP_TEXT = `Workflows CLI — install and manage Plain Concepts Platform agentic workflows.
|
|
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
|
+
|
|
17
|
+
Usage: workflows <command> [options]
|
|
18
|
+
|
|
19
|
+
Commands:
|
|
20
|
+
(default) Launch the interactive TUI for selecting and installing items.
|
|
21
|
+
init Inspect the repository and report its stack and visibility.
|
|
22
|
+
add Install package-owned workflow files into .github/.
|
|
23
|
+
update Alias for add. Use --force to overwrite managed files.
|
|
24
|
+
status Print repository inspection as JSON.
|
|
25
|
+
list List all available workflows and templates with install status.
|
|
26
|
+
search <query> Filter workflows and templates by name or description.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--visibility public|private Override repository visibility (init only).
|
|
30
|
+
--template <name> Install a standalone template instead of the catalog.
|
|
31
|
+
Templates: agentics-checks, agentics-maintenance,
|
|
32
|
+
app-ci-dotnet-next, app-ci-node-monorepo,
|
|
33
|
+
opencode.ci.json.
|
|
34
|
+
--force Overwrite managed files that differ from the package source.
|
|
35
|
+
-h, --help Show this help text.
|
|
36
|
+
|
|
37
|
+
Installed workflows are marked [x] when the corresponding .github/workflows/agent-*.md
|
|
38
|
+
file exists relative to the current directory.`;
|
|
7
39
|
export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
8
40
|
const [command, ...options] = arguments_;
|
|
9
|
-
if (command === "--help" || command === "-h"
|
|
10
|
-
console.log(
|
|
41
|
+
if (command === "--help" || command === "-h") {
|
|
42
|
+
console.log(HELP_TEXT);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (command === undefined) {
|
|
46
|
+
const force = options.includes("--force");
|
|
47
|
+
return runInteractive(repositoryPath, { force });
|
|
48
|
+
}
|
|
49
|
+
if (command === "list") {
|
|
50
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
51
|
+
console.log(formatCatalog(entries));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (command === "search") {
|
|
55
|
+
if (options.length === 0 || options.length > 1)
|
|
56
|
+
return fail("search requires exactly one query argument.");
|
|
57
|
+
const allEntries = await listCatalog({ installedPath: repositoryPath });
|
|
58
|
+
const results = searchCatalog(allEntries, options[0]);
|
|
59
|
+
console.log(formatCatalog(results));
|
|
11
60
|
return 0;
|
|
12
61
|
}
|
|
13
62
|
if (command === "init") {
|
|
@@ -16,21 +65,23 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
16
65
|
return fail("--visibility must be public or private.");
|
|
17
66
|
const inspection = await inspectRepository(repositoryPath);
|
|
18
67
|
const resolvedVisibility = await resolveVisibility(repositoryPath, visibility);
|
|
19
|
-
|
|
20
|
-
console.log(JSON.stringify({ command, ...result }, null, 2));
|
|
68
|
+
console.log(JSON.stringify({ command, inspection, visibility: resolvedVisibility }, null, 2));
|
|
21
69
|
return 0;
|
|
22
70
|
}
|
|
23
71
|
if (command === "status") {
|
|
24
72
|
const inspection = await inspectRepository(repositoryPath);
|
|
25
|
-
|
|
26
|
-
console.log(JSON.stringify({ command, inspection, manifest, repositoryConfigPath: repositoryConfigRelativePath }, null, 2));
|
|
73
|
+
console.log(JSON.stringify({ command, inspection }, null, 2));
|
|
27
74
|
return 0;
|
|
28
75
|
}
|
|
29
76
|
if (command === "add" || command === "update") {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
77
|
+
const template = readTemplateOption(options);
|
|
78
|
+
if (template === "invalid")
|
|
79
|
+
return fail(`${command} accepts only --force or --template agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json.`);
|
|
80
|
+
const force = options.includes("--force");
|
|
81
|
+
const result = template === undefined
|
|
82
|
+
? await installCatalog(repositoryPath, { force })
|
|
83
|
+
: await installTemplate(repositoryPath, template, { force });
|
|
84
|
+
if (result.conflicts.length > 0 && !force) {
|
|
34
85
|
console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${result.conflicts.join("\n")}`);
|
|
35
86
|
return 1;
|
|
36
87
|
}
|
|
@@ -46,6 +97,19 @@ function readVisibilityOption(options) {
|
|
|
46
97
|
return "invalid";
|
|
47
98
|
return parseVisibility(options[1]) ?? "invalid";
|
|
48
99
|
}
|
|
100
|
+
function readTemplateOption(options) {
|
|
101
|
+
const templateIndex = options.indexOf("--template");
|
|
102
|
+
if (templateIndex === -1)
|
|
103
|
+
return options.every((option) => option === "--force") ? undefined : "invalid";
|
|
104
|
+
if (templateIndex + 1 >= options.length || options.filter((option) => option === "--template").length !== 1)
|
|
105
|
+
return "invalid";
|
|
106
|
+
if (options.some((option, index) => option !== "--force" && index !== templateIndex && index !== templateIndex + 1))
|
|
107
|
+
return "invalid";
|
|
108
|
+
return templateNameFromOptions(options[templateIndex + 1]);
|
|
109
|
+
}
|
|
110
|
+
function templateNameFromOptions(value) {
|
|
111
|
+
return value !== undefined && isTemplateName(value) ? value : "invalid";
|
|
112
|
+
}
|
|
49
113
|
function fail(message) {
|
|
50
114
|
console.error(message);
|
|
51
115
|
return 1;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { run } from "./index.js";
|
|
6
|
+
const temporaryDirectories = [];
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
9
|
+
});
|
|
10
|
+
describe("workflows CLI", () => {
|
|
11
|
+
it("prints template names in help", async () => {
|
|
12
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
13
|
+
await expect(run(["--help"])).resolves.toBe(0);
|
|
14
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("agentics-checks"));
|
|
15
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("app-ci-dotnet-next"));
|
|
16
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode.ci.json"));
|
|
17
|
+
log.mockRestore();
|
|
18
|
+
});
|
|
19
|
+
it("rejects an unsupported template", async () => {
|
|
20
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
21
|
+
await expect(run(["add", "--template", "unknown"])).resolves.toBe(1);
|
|
22
|
+
expect(error).toHaveBeenCalledWith("add accepts only --force or --template agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json.");
|
|
23
|
+
error.mockRestore();
|
|
24
|
+
});
|
|
25
|
+
it("lists all workflows and templates with install status [ ] when none installed", async () => {
|
|
26
|
+
const repositoryPath = await createRepository({});
|
|
27
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
28
|
+
await expect(run(["list"], repositoryPath)).resolves.toBe(0);
|
|
29
|
+
expect(log).toHaveBeenCalledTimes(1);
|
|
30
|
+
const output = log.mock.calls[0][0];
|
|
31
|
+
expect(output).toContain("Workflows:");
|
|
32
|
+
expect(output).toContain("Templates:");
|
|
33
|
+
expect(output).toContain("refine");
|
|
34
|
+
expect(output).toContain("implement");
|
|
35
|
+
expect(output).toContain("agentics-checks");
|
|
36
|
+
expect(output).toContain("app-ci-dotnet-next");
|
|
37
|
+
// None installed: all [ ]
|
|
38
|
+
const installedCount = (output.match(/\[x\]/g) ?? []).length;
|
|
39
|
+
expect(installedCount).toBe(0);
|
|
40
|
+
// 7 routes + 5 templates = 12 entries
|
|
41
|
+
const uninstalledCount = (output.match(/\[ \]/g) ?? []).length;
|
|
42
|
+
expect(uninstalledCount).toBe(12);
|
|
43
|
+
log.mockRestore();
|
|
44
|
+
});
|
|
45
|
+
it("marks installed workflows with [x]", async () => {
|
|
46
|
+
const repositoryPath = await createRepository({
|
|
47
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
48
|
+
".github/workflows/agentics-checks.yml": "name: Agentics checks",
|
|
49
|
+
});
|
|
50
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
51
|
+
await expect(run(["list"], repositoryPath)).resolves.toBe(0);
|
|
52
|
+
const output = log.mock.calls[0][0];
|
|
53
|
+
const installed = output.match(/(\[x\])/g) ?? [];
|
|
54
|
+
expect(installed.length).toBe(2);
|
|
55
|
+
// refine and agentics-checks should be [x]
|
|
56
|
+
const refineLines = output.split("\n").filter((line) => line.includes("refine") || line.includes("agentics-checks"));
|
|
57
|
+
expect(refineLines.filter((line) => line.includes("[x]"))).toHaveLength(2);
|
|
58
|
+
log.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
it("search filters by name", async () => {
|
|
61
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
62
|
+
await expect(run(["search", "audit"])).resolves.toBe(0);
|
|
63
|
+
const output = log.mock.calls[0][0];
|
|
64
|
+
expect(output).toContain("audit");
|
|
65
|
+
expect(output).not.toContain("Templates:");
|
|
66
|
+
// Only the audit route should be returned — no other route name should appear.
|
|
67
|
+
const visibleRoutes = ["refine", "implement", "direct", "apply-review", "merge-gate", "propose"];
|
|
68
|
+
for (const route of visibleRoutes) {
|
|
69
|
+
expect(output).not.toContain(`${route} —`);
|
|
70
|
+
}
|
|
71
|
+
log.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
it("search filters by description keyword", async () => {
|
|
74
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
75
|
+
await expect(run(["search", "CI"])).resolves.toBe(0);
|
|
76
|
+
const output = log.mock.calls[0][0];
|
|
77
|
+
expect(output).toContain("dotnet-next");
|
|
78
|
+
expect(output).toContain("node-monorepo");
|
|
79
|
+
expect(output).not.toContain("refine");
|
|
80
|
+
log.mockRestore();
|
|
81
|
+
});
|
|
82
|
+
it("search with no matches prints a no-match message", async () => {
|
|
83
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
84
|
+
await expect(run(["search", "nonexistent"])).resolves.toBe(0);
|
|
85
|
+
const output = log.mock.calls[0][0];
|
|
86
|
+
expect(output).toBe("No workflows matched the search query.");
|
|
87
|
+
log.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
it("search with no query fails", async () => {
|
|
90
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
91
|
+
await expect(run(["search"])).resolves.toBe(1);
|
|
92
|
+
expect(error).toHaveBeenCalledWith("search requires exactly one query argument.");
|
|
93
|
+
error.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
it("search with too many arguments fails", async () => {
|
|
96
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
97
|
+
await expect(run(["search", "a", "b"])).resolves.toBe(1);
|
|
98
|
+
expect(error).toHaveBeenCalledWith("search requires exactly one query argument.");
|
|
99
|
+
error.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
async function createRepository(files) {
|
|
103
|
+
const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
|
|
104
|
+
temporaryDirectories.push(repositoryPath);
|
|
105
|
+
await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
|
|
106
|
+
const path = join(repositoryPath, relativePath);
|
|
107
|
+
const { mkdir } = await import("node:fs/promises");
|
|
108
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
109
|
+
await writeFile(path, content, "utf8");
|
|
110
|
+
}));
|
|
111
|
+
return repositoryPath;
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { inspectRepository, resolveVisibility } from "./repository-inspection.js";
|
|
6
|
+
import { run } from "./index.js";
|
|
7
|
+
const temporaryDirectories = [];
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
10
|
+
});
|
|
11
|
+
describe("repository inspection", () => {
|
|
12
|
+
it("finds agent workflows and supported stack markers", async () => {
|
|
13
|
+
const repositoryPath = await createRepository({
|
|
14
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
15
|
+
".github/workflows/agent-custom.md": "# Custom",
|
|
16
|
+
".github/workflows/other.md": "# Other",
|
|
17
|
+
"package.json": "{}",
|
|
18
|
+
"pnpm-lock.yaml": "lockfileVersion: '9.0'",
|
|
19
|
+
"apps/api/Numa.slnx": "<Solution />",
|
|
20
|
+
"openspec/changes/.gitkeep": "",
|
|
21
|
+
});
|
|
22
|
+
await expect(inspectRepository(repositoryPath)).resolves.toMatchObject({
|
|
23
|
+
existingAgentWorkflows: ["agent-custom.md", "agent-refine.md"],
|
|
24
|
+
stackHints: {
|
|
25
|
+
packageJson: true,
|
|
26
|
+
pnpmLockfile: true,
|
|
27
|
+
solutionFiles: [join(repositoryPath, "apps", "api", "Numa.slnx")],
|
|
28
|
+
openSpec: true,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it("uses an explicit visibility before environment or GitHub", async () => {
|
|
33
|
+
const runner = { run: async () => '{"visibility":"public"}' };
|
|
34
|
+
await expect(resolveVisibility("repo", "private", { PLATFORM_WORKFLOWS_VISIBILITY: "public" }, runner)).resolves.toEqual({
|
|
35
|
+
value: "private",
|
|
36
|
+
source: "argument",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
it("falls back to private when GitHub CLI is unavailable", async () => {
|
|
40
|
+
const runner = { run: async () => Promise.reject(new Error("missing gh")) };
|
|
41
|
+
await expect(resolveVisibility("repo", undefined, {}, runner)).resolves.toEqual({ value: "private", source: "fallback" });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("CLI commands", () => {
|
|
45
|
+
it("keeps init available without writing configuration", async () => {
|
|
46
|
+
const repositoryPath = await createRepository({ "package.json": "{}" });
|
|
47
|
+
const output = captureConsole("log");
|
|
48
|
+
await expect(run(["init", "--visibility", "public"], repositoryPath)).resolves.toBe(0);
|
|
49
|
+
expect(output.calls).toHaveLength(1);
|
|
50
|
+
await expect(import("node:fs/promises").then(({ access }) => access(join(repositoryPath, ".github/workflows/shared/repo-config.md")))).rejects.toThrow();
|
|
51
|
+
output.restore();
|
|
52
|
+
});
|
|
53
|
+
it("accepts update as an add alias", async () => {
|
|
54
|
+
const repositoryPath = await createRepository({});
|
|
55
|
+
const error = captureConsole("error");
|
|
56
|
+
await expect(run(["update", "--invalid"], repositoryPath)).resolves.toBe(1);
|
|
57
|
+
expect(error.calls).toEqual(["update accepts only --force or --template agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json."]);
|
|
58
|
+
error.restore();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
function captureConsole(method) {
|
|
62
|
+
const original = console[method];
|
|
63
|
+
const calls = [];
|
|
64
|
+
console[method] = (message) => calls.push(message);
|
|
65
|
+
return { calls, restore: () => { console[method] = original; } };
|
|
66
|
+
}
|
|
67
|
+
async function createRepository(files) {
|
|
68
|
+
const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
|
|
69
|
+
temporaryDirectories.push(repositoryPath);
|
|
70
|
+
await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
|
|
71
|
+
const path = join(repositoryPath, relativePath);
|
|
72
|
+
const { mkdir } = await import("node:fs/promises");
|
|
73
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
74
|
+
await writeFile(path, content, "utf8");
|
|
75
|
+
}));
|
|
76
|
+
return repositoryPath;
|
|
77
|
+
}
|
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>;
|