@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.
Files changed (71) hide show
  1. package/README.md +40 -11
  2. package/dist/catalog-installation.d.ts +11 -2
  3. package/dist/catalog-installation.js +53 -9
  4. package/dist/catalog-installation.test.js +198 -16
  5. package/dist/catalog-listing.d.ts +13 -0
  6. package/dist/catalog-listing.js +68 -0
  7. package/dist/catalog-listing.test.d.ts +1 -0
  8. package/dist/catalog-listing.test.js +137 -0
  9. package/dist/index.js +76 -12
  10. package/dist/index.test.d.ts +1 -0
  11. package/dist/index.test.js +112 -0
  12. package/dist/repository-inspection.test.d.ts +1 -0
  13. package/dist/repository-inspection.test.js +77 -0
  14. package/dist/tui.d.ts +25 -0
  15. package/dist/tui.js +254 -0
  16. package/dist/tui.test.d.ts +1 -0
  17. package/dist/tui.test.js +246 -0
  18. package/dist/workflow-catalog.d.ts +15 -8
  19. package/dist/workflow-catalog.js +25 -15
  20. package/dist/workflow-catalog.test.js +18 -3
  21. package/loops/actions/add-issue-labels/action.yml +30 -29
  22. package/loops/actions/agent-output.cjs +1 -0
  23. package/loops/actions/apply-agent-bundle/action.yml +24 -23
  24. package/loops/actions/apply-agent-bundle/apply-bundle.sh +1 -0
  25. package/loops/actions/apply-agent-comments/action.yml +42 -41
  26. package/loops/actions/apply-agent-labels/action.yml +55 -54
  27. package/loops/actions/apply-agent-output/action.yml +108 -107
  28. package/loops/actions/audit-close/action.yml +104 -103
  29. package/loops/actions/classify-route/action.yml +91 -90
  30. package/loops/actions/classify-route/classify-route.sh +1 -0
  31. package/loops/actions/cleanup-artifacts/action.yml +65 -64
  32. package/loops/actions/close-agent-issues/action.yml +43 -42
  33. package/loops/actions/create-agent-issues/action.yml +52 -51
  34. package/loops/actions/create-issue-comment/action.yml +29 -28
  35. package/loops/actions/download-agent-output/action.yml +53 -52
  36. package/loops/actions/identify-gate-subject/action.yml +97 -96
  37. package/loops/actions/link-pr-to-issue/action.yml +40 -39
  38. package/loops/actions/list-open-issues/action.yml +33 -32
  39. package/loops/actions/load-issue-context/action.yml +43 -42
  40. package/loops/actions/merge-agent-pr/action.yml +36 -35
  41. package/loops/actions/push-agent-branch/action.yml +45 -44
  42. package/loops/actions/remove-issue-labels/action.yml +35 -34
  43. package/loops/actions/stale-recovery/action.yml +288 -287
  44. package/loops/actions/update-agent-issues/action.yml +58 -57
  45. package/loops/actions/validate-refine-output/action.yml +44 -43
  46. package/loops/actions/validate-refine-output/validate-refine-output.sh +1 -0
  47. package/loops/actions/verify-composite-actions/action.yml +9 -8
  48. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +1 -0
  49. package/loops/actions/verify-refine-output/action.yml +9 -8
  50. package/loops/actions/verify-refine-output/verify-refine-output.sh +1 -0
  51. package/loops/actions/verify-route-matrix/action.yml +9 -8
  52. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1 -0
  53. package/loops/scripts/compile-agent-workflows.mjs +29 -13
  54. package/loops/templates/agentics/agentics-checks.yml +86 -0
  55. package/loops/templates/agentics/agentics-maintenance.yml +121 -0
  56. package/loops/templates/ci/app-ci-dotnet-next.yml +167 -0
  57. package/loops/templates/ci/app-ci-node-monorepo.yml +178 -0
  58. package/loops/templates/opencode/opencode.ci.json +46 -0
  59. package/loops/templates/opencode/opencode.ci.json.md +41 -0
  60. package/loops/workflows/agent-apply-review.md +9 -10
  61. package/loops/workflows/agent-audit.md +6 -7
  62. package/loops/workflows/agent-direct.md +7 -8
  63. package/loops/workflows/agent-implement.md +7 -8
  64. package/loops/workflows/agent-merge-gate.md +9 -10
  65. package/loops/workflows/agent-propose.md +7 -8
  66. package/loops/workflows/agent-refine.md +5 -7
  67. package/loops/workflows/shared/opencode-ci.md +105 -1
  68. package/loops/workflows/shared/platform-defaults.md +1 -0
  69. package/loops/workflows/work-router.yml +1 -0
  70. package/package.json +2 -2
  71. package/loops/aw/repo-config.md +0 -12
package/README.md CHANGED
@@ -1,30 +1,59 @@
1
- # Platform workflows
1
+ # Workflows CLI
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 one-off use, prefer:
19
+ For non-interactive use (advanced):
10
20
 
11
21
  ```bash
12
- npx @plainconceptsplatform/workflows@latest platform-workflows init
13
- npx @plainconceptsplatform/workflows@latest platform-workflows add
14
- npx @plainconceptsplatform/workflows@latest platform-workflows update
22
+ npx @plainconceptsplatform/workflows@latest init
23
+ npx @plainconceptsplatform/workflows@latest add
24
+ npx @plainconceptsplatform/workflows@latest update
15
25
  ```
16
26
 
17
27
  For a project-local development dependency:
18
28
 
19
29
  ```bash
20
30
  pnpm add -D @plainconceptsplatform/workflows
21
- pnpm exec platform-workflows init
22
- pnpm exec platform-workflows add
31
+ pnpm exec workflows # launch interactive TUI
32
+ pnpm exec workflows init
33
+ pnpm exec workflows add
23
34
  ```
24
35
 
25
- `init` detects the repository stack and visibility, creates `.github/workflows/shared/repo-config.md` when absent, and records managed workflow state. Complete the consumer configuration before compiling workflows.
36
+ `init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
37
+
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.
39
+
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.
41
+
42
+ ## List and search
26
43
 
27
- `add` installs missing package-owned files. It stops when a managed file differs. Use `pnpm exec platform-workflows update --force` only when you intend to replace managed workflow files. It never overwrites `repo-config.md`.
44
+ List all available workflows, routes, and templates with install status:
45
+
46
+ ```bash
47
+ npx --yes --package @plainconceptsplatform/workflows@latest workflows list
48
+ ```
49
+
50
+ Each entry is marked `[x]` when the corresponding `.github/workflows/agent-*.md` file already exists in the current directory, or `[ ]` when it is not yet installed.
51
+
52
+ Search by name or description:
53
+
54
+ ```bash
55
+ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci"
56
+ ```
28
57
 
29
58
  ## Manual installation
30
59
 
@@ -32,10 +61,10 @@ The package includes `loops/`, a copyable equivalent of `.github/`:
32
61
 
33
62
  - `loops/actions/` maps to `.github/actions/`
34
63
  - `loops/workflows/` maps to `.github/workflows/`
35
- - `loops/aw/repo-config.md` maps to `.github/workflows/shared/repo-config.md`
36
64
  - `loops/scripts/` maps to `scripts/`
37
65
 
38
- Copy these files manually if you do not use the CLI. Keep `repo-config.md` consumer-owned.
66
+ Copy these files manually if you do not use the CLI. Each worker is self-contained. Edit its
67
+ top-level `env:` defaults directly for consumer-specific endpoint, model, labels, paths, and checks.
39
68
 
40
69
  ## Compile
41
70
 
@@ -1,4 +1,4 @@
1
- import { repositoryConfigRelativePath } from "./repository-state.js";
1
+ import { type TemplateName } from "./workflow-catalog.js";
2
2
  export interface CatalogInstallResult {
3
3
  readonly installed: readonly string[];
4
4
  readonly conflicts: readonly string[];
@@ -7,6 +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
- export { repositoryConfigRelativePath };
18
+ export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
19
+ export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
20
+ export declare function isTemplateName(value: string): value is TemplateName;
21
+ export {};
@@ -2,34 +2,83 @@ 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 { repositoryConfigRelativePath } from "./repository-state.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 managedFiles = files.filter((file) => file.managed);
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(files.map(async (file) => {
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
  }));
39
+ return { installed: deduplicated.map((file) => file.target), conflicts };
40
+ }
41
+ export async function installTemplate(repositoryPath, template, options = {}) {
42
+ const sourcePath = options.sourcePath ?? catalogSourcePath();
43
+ const meta = catalogTemplateMeta(template);
44
+ const source = join(sourcePath, "templates", meta.directory, meta.file);
45
+ const target = meta.target;
46
+ const destination = join(repositoryPath, target);
47
+ const conflicts = await exists(destination) && !(await filesMatch(source, destination)) ? [target] : [];
48
+ if (conflicts.length > 0 && !options.force)
49
+ return { installed: [], conflicts };
50
+ await mkdir(dirname(destination), { recursive: true });
51
+ await copyFile(source, destination);
52
+ return { installed: [target], conflicts };
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
+ }));
31
68
  return { installed: files.map((file) => file.target), conflicts };
32
69
  }
70
+ export function isTemplateName(value) {
71
+ return templateNames.includes(value);
72
+ }
73
+ function catalogTemplateMeta(template) {
74
+ const entry = catalogTemplates.find((item) => item.name === template);
75
+ if (entry === undefined)
76
+ throw new Error(`Unknown template: ${template}`);
77
+ const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : "agentics";
78
+ const isWorkflow = entry.file.endsWith(".yml");
79
+ const target = isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
80
+ return { directory, file: entry.file, target };
81
+ }
33
82
  async function catalogFiles(sourcePath) {
34
83
  const files = [];
35
84
  for (const [sourceDirectory, targetDirectory] of sourceMappings) {
@@ -43,10 +92,6 @@ async function catalogFiles(sourcePath) {
43
92
  });
44
93
  }
45
94
  }
46
- const repositoryConfigSource = join(sourcePath, "aw", "repo-config.md");
47
- if (await exists(repositoryConfigSource)) {
48
- files.push({ source: repositoryConfigSource, target: repositoryConfigRelativePath, managed: false });
49
- }
50
95
  return files.sort((left, right) => left.target.localeCompare(right.target));
51
96
  }
52
97
  function isGeneratedFile(file) {
@@ -77,4 +122,3 @@ async function exists(path) {
77
122
  return false;
78
123
  }
79
124
  }
80
- export { repositoryConfigRelativePath };
@@ -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, repositoryConfigRelativePath } 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,13 +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 and initializes repository config", 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",
24
- "aw/repo-config.md": "# Repository configuration\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",
25
26
  "workflows/agent-check.lock.yml": "generated\n",
26
27
  "actions/actions-lock.json": "generated\n",
27
28
  });
@@ -31,56 +32,234 @@ describe("catalog installation", () => {
31
32
  ".github/actions/check/action.yml",
32
33
  ".github/workflows/agent-check.md",
33
34
  ".github/workflows/shared/defaults.md",
34
- repositoryConfigRelativePath,
35
+ "opencode.ci.json",
36
+ "scripts/compile-agent-workflows.mjs",
35
37
  "scripts/compile.mjs",
36
38
  ],
37
39
  conflicts: [],
38
40
  });
39
- await expect(readFile(join(repositoryPath, repositoryConfigRelativePath), "utf8")).resolves.toBe("# Repository configuration\n");
41
+ });
42
+ it("copies ownership headers for base workflows and selected templates", async () => {
43
+ const header = "# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-check.md. Update with `workflows update --force`; consumer edits may be overwritten.\n";
44
+ const templateHeader = "# Managed by @plainconceptsplatform/workflows. Source: loops/templates/agentics/agentics-checks.yml. Update with `workflows update --force`; consumer edits may be overwritten.\n";
45
+ const sourcePath = await createDirectory({
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",
47
+ "workflows/agent-check.md": `---\n${header}# Check\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",
50
+ "templates/agentics/agentics-checks.yml": `${templateHeader}name: Agentics checks\n`,
51
+ });
52
+ const repositoryPath = await createDirectory({});
53
+ await installCatalog(repositoryPath, { sourcePath });
54
+ await installTemplate(repositoryPath, "agentics-checks", { sourcePath });
55
+ await expect(readFile(join(repositoryPath, ".github/workflows/agent-check.md"), "utf8")).resolves.toMatch(new RegExp(`^---\\n${escapeRegularExpression(header)}`));
56
+ await expect(readFile(join(repositoryPath, ".github/workflows/agentics-checks.yml"), "utf8")).resolves.toMatch(new RegExp(`^${escapeRegularExpression(templateHeader)}`));
40
57
  });
41
58
  it("does not report identical managed files as conflicts", async () => {
42
59
  const sourcePath = await createDirectory({
43
60
  "actions/check/action.yml": "name: Check\n",
44
61
  "workflows/agent-check.md": "# Check\n",
45
- "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",
46
64
  });
47
65
  const repositoryPath = await createDirectory({ ".github/workflows/agent-check.md": "# Check\n" });
48
66
  await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toMatchObject({ conflicts: [] });
49
67
  });
50
- it("requires force for different managed files and preserves repository config", async () => {
68
+ it("does not manage legacy repository configuration files", async () => {
69
+ const sourcePath = await createDirectory({
70
+ "actions/check/action.yml": "name: Check\n",
71
+ "workflows/agent-check.md": "# Check\n",
72
+ "scripts/compile-agent-workflows.mjs": "compile\n",
73
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
74
+ });
75
+ const repositoryPath = await createDirectory({
76
+ ".github/workflows/shared/repo-config.md": "legacy consumer config\n",
77
+ });
78
+ await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toMatchObject({
79
+ installed: [
80
+ ".github/actions/check/action.yml",
81
+ ".github/workflows/agent-check.md",
82
+ "opencode.ci.json",
83
+ "scripts/compile-agent-workflows.mjs",
84
+ ],
85
+ conflicts: [],
86
+ });
87
+ await expect(readFile(join(repositoryPath, ".github/workflows/shared/repo-config.md"), "utf8")).resolves.toBe("legacy consumer config\n");
88
+ });
89
+ it("requires force for different managed files", async () => {
51
90
  const sourcePath = await createDirectory({
52
91
  "actions/check/action.yml": "package action\n",
53
92
  "workflows/agent-check.md": "package workflow\n",
54
- "scripts/compile.mjs": "package script\n",
55
- "aw/repo-config.md": "package config\n",
93
+ "scripts/compile-agent-workflows.mjs": "package script\n",
94
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
56
95
  });
57
96
  const repositoryPath = await createDirectory({
58
97
  ".github/actions/check/action.yml": "consumer action\n",
59
98
  ".github/workflows/agent-check.md": "consumer workflow\n",
60
- "scripts/compile.mjs": "consumer script\n",
61
- [repositoryConfigRelativePath]: "consumer config\n",
99
+ "scripts/compile-agent-workflows.mjs": "consumer script\n",
100
+ "opencode.ci.json": "{ \"model\": \"consumer-model\" }\n",
62
101
  });
63
102
  await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toEqual({
64
103
  installed: [],
65
104
  conflicts: [
66
105
  ".github/actions/check/action.yml",
67
106
  ".github/workflows/agent-check.md",
68
- "scripts/compile.mjs",
107
+ "opencode.ci.json",
108
+ "scripts/compile-agent-workflows.mjs",
69
109
  ],
70
110
  });
71
111
  await expect(installCatalog(repositoryPath, { force: true, sourcePath })).resolves.toMatchObject({
72
112
  conflicts: [
73
113
  ".github/actions/check/action.yml",
74
114
  ".github/workflows/agent-check.md",
75
- "scripts/compile.mjs",
115
+ "opencode.ci.json",
116
+ "scripts/compile-agent-workflows.mjs",
76
117
  ],
77
118
  });
78
119
  await expect(readFile(join(repositoryPath, ".github/actions/check/action.yml"), "utf8")).resolves.toBe("package action\n");
79
- await expect(readFile(join(repositoryPath, repositoryConfigRelativePath), "utf8")).resolves.toBe("consumer config\n");
120
+ });
121
+ it("installs templates only when explicitly selected", async () => {
122
+ const sourcePath = await createDirectory({
123
+ "actions/check/action.yml": "name: Check\n",
124
+ "workflows/agent-check.md": "# Check\n",
125
+ "scripts/compile-agent-workflows.mjs": "compile\n",
126
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
127
+ "templates/agentics/agentics-checks.yml": "name: Agentics checks\n",
128
+ "templates/ci/app-ci-node-monorepo.yml": "name: Node CI\n",
129
+ });
130
+ const repositoryPath = await createDirectory({});
131
+ await installCatalog(repositoryPath, { sourcePath });
132
+ await expect(readFile(join(repositoryPath, ".github/workflows/agentics-checks.yml"), "utf8")).rejects.toThrow();
133
+ await expect(installTemplate(repositoryPath, "agentics-checks", { sourcePath })).resolves.toEqual({
134
+ installed: [".github/workflows/agentics-checks.yml"],
135
+ conflicts: [],
136
+ });
137
+ await expect(installTemplate(repositoryPath, "app-ci-node-monorepo", { sourcePath })).resolves.toEqual({
138
+ installed: [".github/workflows/app-ci-node-monorepo.yml"],
139
+ conflicts: [],
140
+ });
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
+ });
218
+ it("installs the opencode.ci.json template to the repository root", async () => {
219
+ const sourcePath = await createDirectory({
220
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
221
+ });
222
+ const repositoryPath = await createDirectory({});
223
+ await expect(installTemplate(repositoryPath, "opencode.ci.json", { sourcePath })).resolves.toEqual({
224
+ installed: ["opencode.ci.json"],
225
+ conflicts: [],
226
+ });
227
+ await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
228
+ });
229
+ it("requires force to replace the opencode.ci.json template", async () => {
230
+ const sourcePath = await createDirectory({
231
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
232
+ });
233
+ const repositoryPath = await createDirectory({
234
+ "opencode.ci.json": "{ \"model\": \"consumer-model\" }\n",
235
+ });
236
+ await expect(installTemplate(repositoryPath, "opencode.ci.json", { sourcePath })).resolves.toEqual({
237
+ installed: [],
238
+ conflicts: ["opencode.ci.json"],
239
+ });
240
+ await expect(installTemplate(repositoryPath, "opencode.ci.json", { force: true, sourcePath })).resolves.toMatchObject({
241
+ installed: ["opencode.ci.json"],
242
+ });
243
+ await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
244
+ });
245
+ it("requires force to replace a selected template", async () => {
246
+ const sourcePath = await createDirectory({
247
+ "templates/agentics/agentics-checks.yml": "package template\n",
248
+ });
249
+ const repositoryPath = await createDirectory({
250
+ ".github/workflows/agentics-checks.yml": "consumer template\n",
251
+ });
252
+ await expect(installTemplate(repositoryPath, "agentics-checks", { sourcePath })).resolves.toEqual({
253
+ installed: [],
254
+ conflicts: [".github/workflows/agentics-checks.yml"],
255
+ });
256
+ await expect(installTemplate(repositoryPath, "agentics-checks", { force: true, sourcePath })).resolves.toMatchObject({
257
+ installed: [".github/workflows/agentics-checks.yml"],
258
+ });
80
259
  });
81
260
  });
82
261
  async function createDirectory(files) {
83
- const directory = await mkdtemp(join(tmpdir(), "platform-workflows-"));
262
+ const directory = await mkdtemp(join(tmpdir(), "workflows-"));
84
263
  temporaryDirectories.push(directory);
85
264
  await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
86
265
  const path = join(directory, relativePath);
@@ -90,3 +269,6 @@ async function createDirectory(files) {
90
269
  }));
91
270
  return directory;
92
271
  }
272
+ function escapeRegularExpression(value) {
273
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
274
+ }
@@ -0,0 +1,13 @@
1
+ export interface CatalogEntry {
2
+ readonly kind: "route" | "template";
3
+ readonly name: string;
4
+ readonly description: string;
5
+ readonly file: string;
6
+ readonly installed: boolean;
7
+ }
8
+ export interface ListOptions {
9
+ readonly installedPath?: string;
10
+ }
11
+ export declare function listCatalog(options?: ListOptions): Promise<readonly CatalogEntry[]>;
12
+ export declare function searchCatalog(entries: readonly CatalogEntry[], query: string): readonly CatalogEntry[];
13
+ export declare function formatCatalog(entries: readonly CatalogEntry[]): string;
@@ -0,0 +1,68 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { catalogTemplates, workflowRoutes } from "./workflow-catalog.js";
4
+ export async function listCatalog(options = {}) {
5
+ const basePath = options.installedPath ?? process.cwd();
6
+ const routes = await Promise.all(workflowRoutes.map(async (route) => ({
7
+ kind: "route",
8
+ name: route.name,
9
+ description: route.description,
10
+ file: route.worker,
11
+ installed: await isFileInstalled(basePath, route.worker),
12
+ })));
13
+ const templates = await Promise.all(catalogTemplates.map(async (template) => ({
14
+ kind: "template",
15
+ name: template.name,
16
+ description: template.description,
17
+ file: template.file,
18
+ installed: await isFileInstalled(basePath, template.file),
19
+ })));
20
+ return [...routes, ...templates];
21
+ }
22
+ export function searchCatalog(entries, query) {
23
+ const normalized = query.toLowerCase().trim();
24
+ if (normalized === "")
25
+ return [];
26
+ return entries.filter((entry) => entry.name.toLowerCase().includes(normalized) ||
27
+ entry.description.toLowerCase().includes(normalized));
28
+ }
29
+ export function formatCatalog(entries) {
30
+ if (entries.length === 0)
31
+ return "No workflows matched the search query.";
32
+ const routes = entries.filter((entry) => entry.kind === "route");
33
+ const templates = entries.filter((entry) => entry.kind === "template");
34
+ const lines = [];
35
+ if (routes.length > 0) {
36
+ lines.push("Workflows:");
37
+ for (const entry of routes) {
38
+ lines.push(formatEntry(entry));
39
+ }
40
+ }
41
+ if (templates.length > 0) {
42
+ if (lines.length > 0)
43
+ lines.push("");
44
+ lines.push("Templates:");
45
+ for (const entry of templates) {
46
+ lines.push(formatEntry(entry));
47
+ }
48
+ }
49
+ return lines.join("\n");
50
+ }
51
+ function formatEntry(entry) {
52
+ const mark = entry.installed ? "[x]" : "[ ]";
53
+ return ` ${mark} ${entry.name} — ${entry.description}`;
54
+ }
55
+ async function isFileInstalled(basePath, workerOrTemplateFile) {
56
+ const installedPath = templateInstallPath(basePath, workerOrTemplateFile);
57
+ try {
58
+ await access(installedPath, constants.F_OK);
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ function templateInstallPath(basePath, file) {
66
+ const isRootTemplate = file.endsWith(".json");
67
+ return isRootTemplate ? join(basePath, file) : join(basePath, ".github", "workflows", file);
68
+ }
@@ -0,0 +1 @@
1
+ export {};