@plainconceptsplatform/workflows 0.1.2 → 0.1.5

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 (67) hide show
  1. package/README.md +28 -10
  2. package/dist/catalog-installation.d.ts +3 -2
  3. package/dist/catalog-installation.js +26 -6
  4. package/dist/catalog-installation.test.js +104 -10
  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 +62 -11
  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/workflow-catalog.d.ts +9 -7
  15. package/dist/workflow-catalog.js +21 -16
  16. package/dist/workflow-catalog.test.js +18 -3
  17. package/loops/actions/add-issue-labels/action.yml +30 -29
  18. package/loops/actions/agent-output.cjs +1 -0
  19. package/loops/actions/apply-agent-bundle/action.yml +24 -23
  20. package/loops/actions/apply-agent-bundle/apply-bundle.sh +1 -0
  21. package/loops/actions/apply-agent-comments/action.yml +42 -41
  22. package/loops/actions/apply-agent-labels/action.yml +55 -54
  23. package/loops/actions/apply-agent-output/action.yml +108 -107
  24. package/loops/actions/audit-close/action.yml +104 -103
  25. package/loops/actions/classify-route/action.yml +91 -90
  26. package/loops/actions/classify-route/classify-route.sh +1 -0
  27. package/loops/actions/cleanup-artifacts/action.yml +65 -64
  28. package/loops/actions/close-agent-issues/action.yml +43 -42
  29. package/loops/actions/create-agent-issues/action.yml +52 -51
  30. package/loops/actions/create-issue-comment/action.yml +29 -28
  31. package/loops/actions/download-agent-output/action.yml +53 -52
  32. package/loops/actions/identify-gate-subject/action.yml +97 -96
  33. package/loops/actions/link-pr-to-issue/action.yml +40 -39
  34. package/loops/actions/list-open-issues/action.yml +33 -32
  35. package/loops/actions/load-issue-context/action.yml +43 -42
  36. package/loops/actions/merge-agent-pr/action.yml +36 -35
  37. package/loops/actions/push-agent-branch/action.yml +45 -44
  38. package/loops/actions/remove-issue-labels/action.yml +35 -34
  39. package/loops/actions/stale-recovery/action.yml +288 -287
  40. package/loops/actions/update-agent-issues/action.yml +58 -57
  41. package/loops/actions/validate-refine-output/action.yml +44 -43
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +1 -0
  43. package/loops/actions/verify-composite-actions/action.yml +9 -8
  44. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +1 -0
  45. package/loops/actions/verify-refine-output/action.yml +9 -8
  46. package/loops/actions/verify-refine-output/verify-refine-output.sh +1 -0
  47. package/loops/actions/verify-route-matrix/action.yml +9 -8
  48. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1 -0
  49. package/loops/scripts/compile-agent-workflows.mjs +18 -2
  50. package/loops/templates/agentics/agentics-checks.yml +86 -0
  51. package/loops/templates/agentics/agentics-maintenance.yml +121 -0
  52. package/loops/templates/ci/app-ci-dotnet-next.yml +167 -0
  53. package/loops/templates/ci/app-ci-node-monorepo.yml +178 -0
  54. package/loops/templates/opencode/opencode.ci.json +46 -0
  55. package/loops/templates/opencode/opencode.ci.json.md +41 -0
  56. package/loops/workflows/agent-apply-review.md +9 -10
  57. package/loops/workflows/agent-audit.md +6 -7
  58. package/loops/workflows/agent-direct.md +7 -8
  59. package/loops/workflows/agent-implement.md +7 -8
  60. package/loops/workflows/agent-merge-gate.md +9 -10
  61. package/loops/workflows/agent-propose.md +7 -8
  62. package/loops/workflows/agent-refine.md +5 -7
  63. package/loops/workflows/shared/opencode-ci.md +1 -0
  64. package/loops/workflows/shared/platform-defaults.md +1 -0
  65. package/loops/workflows/work-router.yml +1 -0
  66. package/package.json +2 -2
  67. package/loops/aw/repo-config.md +0 -12
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Platform workflows
1
+ # Workflows CLI
2
2
 
3
3
  Install and update shared GitHub Agentic Workflows for Plain Concepts Platform repositories.
4
4
 
@@ -9,22 +9,40 @@ Before installing workflows, install and configure [`PlainConceptsPlatform/openc
9
9
  For one-off use, prefer:
10
10
 
11
11
  ```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
12
+ npx @plainconceptsplatform/workflows@latest init
13
+ npx @plainconceptsplatform/workflows@latest add
14
+ npx @plainconceptsplatform/workflows@latest update
15
15
  ```
16
16
 
17
17
  For a project-local development dependency:
18
18
 
19
19
  ```bash
20
20
  pnpm add -D @plainconceptsplatform/workflows
21
- pnpm exec platform-workflows init
22
- pnpm exec platform-workflows add
21
+ pnpm exec workflows init
22
+ pnpm exec workflows add
23
23
  ```
24
24
 
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.
25
+ `init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
26
26
 
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`.
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.
28
+
29
+ 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
+
31
+ ## List and search
32
+
33
+ List all available workflows, routes, and templates with install status:
34
+
35
+ ```bash
36
+ npx --yes --package @plainconceptsplatform/workflows@latest workflows list
37
+ ```
38
+
39
+ 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.
40
+
41
+ Search by name or description:
42
+
43
+ ```bash
44
+ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci"
45
+ ```
28
46
 
29
47
  ## Manual installation
30
48
 
@@ -32,10 +50,10 @@ The package includes `loops/`, a copyable equivalent of `.github/`:
32
50
 
33
51
  - `loops/actions/` maps to `.github/actions/`
34
52
  - `loops/workflows/` maps to `.github/workflows/`
35
- - `loops/aw/repo-config.md` maps to `.github/workflows/shared/repo-config.md`
36
53
  - `loops/scripts/` maps to `scripts/`
37
54
 
38
- Copy these files manually if you do not use the CLI. Keep `repo-config.md` consumer-owned.
55
+ Copy these files manually if you do not use the CLI. Each worker is self-contained. Edit its
56
+ top-level `env:` defaults directly for consumer-specific endpoint, model, labels, paths, and checks.
39
57
 
40
58
  ## Compile
41
59
 
@@ -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[];
@@ -9,4 +9,5 @@ export interface CatalogInstallOptions {
9
9
  }
10
10
  export declare function catalogSourcePath(modulePath?: string): string;
11
11
  export declare function installCatalog(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
12
- export { repositoryConfigRelativePath };
12
+ export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
13
+ export declare function isTemplateName(value: string): value is TemplateName;
@@ -2,7 +2,7 @@ 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, templateNames } from "./workflow-catalog.js";
6
6
  const sourceMappings = [
7
7
  ["actions", ".github/actions"],
8
8
  ["workflows", ".github/workflows"],
@@ -30,6 +30,31 @@ export async function installCatalog(repositoryPath, options = {}) {
30
30
  }));
31
31
  return { installed: files.map((file) => file.target), conflicts };
32
32
  }
33
+ export async function installTemplate(repositoryPath, template, options = {}) {
34
+ const sourcePath = options.sourcePath ?? catalogSourcePath();
35
+ const meta = catalogTemplateMeta(template);
36
+ const source = join(sourcePath, "templates", meta.directory, meta.file);
37
+ const target = meta.target;
38
+ const destination = join(repositoryPath, target);
39
+ const conflicts = await exists(destination) && !(await filesMatch(source, destination)) ? [target] : [];
40
+ if (conflicts.length > 0 && !options.force)
41
+ return { installed: [], conflicts };
42
+ await mkdir(dirname(destination), { recursive: true });
43
+ await copyFile(source, destination);
44
+ return { installed: [target], conflicts };
45
+ }
46
+ export function isTemplateName(value) {
47
+ return templateNames.includes(value);
48
+ }
49
+ function catalogTemplateMeta(template) {
50
+ const entry = catalogTemplates.find((item) => item.name === template);
51
+ if (entry === undefined)
52
+ throw new Error(`Unknown template: ${template}`);
53
+ const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : "agentics";
54
+ const isWorkflow = entry.file.endsWith(".yml");
55
+ const target = isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
56
+ return { directory, file: entry.file, target };
57
+ }
33
58
  async function catalogFiles(sourcePath) {
34
59
  const files = [];
35
60
  for (const [sourceDirectory, targetDirectory] of sourceMappings) {
@@ -43,10 +68,6 @@ async function catalogFiles(sourcePath) {
43
68
  });
44
69
  }
45
70
  }
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
71
  return files.sort((left, right) => left.target.localeCompare(right.target));
51
72
  }
52
73
  function isGeneratedFile(file) {
@@ -77,4 +98,3 @@ async function exists(path) {
77
98
  return false;
78
99
  }
79
100
  }
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, 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,12 @@ 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", 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
23
  "scripts/compile.mjs": "console.log('compile');\n",
24
- "aw/repo-config.md": "# Repository configuration\n",
25
24
  "workflows/agent-check.lock.yml": "generated\n",
26
25
  "actions/actions-lock.json": "generated\n",
27
26
  });
@@ -31,12 +30,25 @@ describe("catalog installation", () => {
31
30
  ".github/actions/check/action.yml",
32
31
  ".github/workflows/agent-check.md",
33
32
  ".github/workflows/shared/defaults.md",
34
- repositoryConfigRelativePath,
35
33
  "scripts/compile.mjs",
36
34
  ],
37
35
  conflicts: [],
38
36
  });
39
- await expect(readFile(join(repositoryPath, repositoryConfigRelativePath), "utf8")).resolves.toBe("# Repository configuration\n");
37
+ });
38
+ it("copies ownership headers for base workflows and selected templates", async () => {
39
+ const header = "# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-check.md. Update with `workflows update --force`; consumer edits may be overwritten.\n";
40
+ const templateHeader = "# Managed by @plainconceptsplatform/workflows. Source: loops/templates/agentics/agentics-checks.yml. Update with `workflows update --force`; consumer edits may be overwritten.\n";
41
+ const sourcePath = await createDirectory({
42
+ "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
+ "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",
45
+ "templates/agentics/agentics-checks.yml": `${templateHeader}name: Agentics checks\n`,
46
+ });
47
+ const repositoryPath = await createDirectory({});
48
+ await installCatalog(repositoryPath, { sourcePath });
49
+ await installTemplate(repositoryPath, "agentics-checks", { sourcePath });
50
+ await expect(readFile(join(repositoryPath, ".github/workflows/agent-check.md"), "utf8")).resolves.toMatch(new RegExp(`^---\\n${escapeRegularExpression(header)}`));
51
+ await expect(readFile(join(repositoryPath, ".github/workflows/agentics-checks.yml"), "utf8")).resolves.toMatch(new RegExp(`^${escapeRegularExpression(templateHeader)}`));
40
52
  });
41
53
  it("does not report identical managed files as conflicts", async () => {
42
54
  const sourcePath = await createDirectory({
@@ -47,18 +59,35 @@ describe("catalog installation", () => {
47
59
  const repositoryPath = await createDirectory({ ".github/workflows/agent-check.md": "# Check\n" });
48
60
  await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toMatchObject({ conflicts: [] });
49
61
  });
50
- it("requires force for different managed files and preserves repository config", async () => {
62
+ it("does not manage legacy repository configuration files", async () => {
63
+ const sourcePath = await createDirectory({
64
+ "actions/check/action.yml": "name: Check\n",
65
+ "workflows/agent-check.md": "# Check\n",
66
+ "scripts/compile.mjs": "compile\n",
67
+ });
68
+ const repositoryPath = await createDirectory({
69
+ ".github/workflows/shared/repo-config.md": "legacy consumer config\n",
70
+ });
71
+ await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toMatchObject({
72
+ installed: [
73
+ ".github/actions/check/action.yml",
74
+ ".github/workflows/agent-check.md",
75
+ "scripts/compile.mjs",
76
+ ],
77
+ conflicts: [],
78
+ });
79
+ await expect(readFile(join(repositoryPath, ".github/workflows/shared/repo-config.md"), "utf8")).resolves.toBe("legacy consumer config\n");
80
+ });
81
+ it("requires force for different managed files", async () => {
51
82
  const sourcePath = await createDirectory({
52
83
  "actions/check/action.yml": "package action\n",
53
84
  "workflows/agent-check.md": "package workflow\n",
54
85
  "scripts/compile.mjs": "package script\n",
55
- "aw/repo-config.md": "package config\n",
56
86
  });
57
87
  const repositoryPath = await createDirectory({
58
88
  ".github/actions/check/action.yml": "consumer action\n",
59
89
  ".github/workflows/agent-check.md": "consumer workflow\n",
60
90
  "scripts/compile.mjs": "consumer script\n",
61
- [repositoryConfigRelativePath]: "consumer config\n",
62
91
  });
63
92
  await expect(installCatalog(repositoryPath, { sourcePath })).resolves.toEqual({
64
93
  installed: [],
@@ -76,11 +105,73 @@ describe("catalog installation", () => {
76
105
  ],
77
106
  });
78
107
  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");
108
+ });
109
+ it("installs templates only when explicitly selected", async () => {
110
+ const sourcePath = await createDirectory({
111
+ "actions/check/action.yml": "name: Check\n",
112
+ "workflows/agent-check.md": "# Check\n",
113
+ "scripts/compile.mjs": "compile\n",
114
+ "templates/agentics/agentics-checks.yml": "name: Agentics checks\n",
115
+ "templates/ci/app-ci-node-monorepo.yml": "name: Node CI\n",
116
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
117
+ });
118
+ const repositoryPath = await createDirectory({});
119
+ await installCatalog(repositoryPath, { sourcePath });
120
+ await expect(readFile(join(repositoryPath, ".github/workflows/agentics-checks.yml"), "utf8")).rejects.toThrow();
121
+ await expect(installTemplate(repositoryPath, "agentics-checks", { sourcePath })).resolves.toEqual({
122
+ installed: [".github/workflows/agentics-checks.yml"],
123
+ conflicts: [],
124
+ });
125
+ await expect(installTemplate(repositoryPath, "app-ci-node-monorepo", { sourcePath })).resolves.toEqual({
126
+ installed: [".github/workflows/app-ci-node-monorepo.yml"],
127
+ conflicts: [],
128
+ });
129
+ });
130
+ it("installs the opencode.ci.json template to the repository root", async () => {
131
+ const sourcePath = await createDirectory({
132
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
133
+ });
134
+ const repositoryPath = await createDirectory({});
135
+ await expect(installTemplate(repositoryPath, "opencode.ci.json", { sourcePath })).resolves.toEqual({
136
+ installed: ["opencode.ci.json"],
137
+ conflicts: [],
138
+ });
139
+ await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
140
+ });
141
+ it("requires force to replace the opencode.ci.json template", async () => {
142
+ const sourcePath = await createDirectory({
143
+ "templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
144
+ });
145
+ const repositoryPath = await createDirectory({
146
+ "opencode.ci.json": "{ \"model\": \"consumer-model\" }\n",
147
+ });
148
+ await expect(installTemplate(repositoryPath, "opencode.ci.json", { sourcePath })).resolves.toEqual({
149
+ installed: [],
150
+ conflicts: ["opencode.ci.json"],
151
+ });
152
+ await expect(installTemplate(repositoryPath, "opencode.ci.json", { force: true, sourcePath })).resolves.toMatchObject({
153
+ installed: ["opencode.ci.json"],
154
+ });
155
+ await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
156
+ });
157
+ it("requires force to replace a selected template", async () => {
158
+ const sourcePath = await createDirectory({
159
+ "templates/agentics/agentics-checks.yml": "package template\n",
160
+ });
161
+ const repositoryPath = await createDirectory({
162
+ ".github/workflows/agentics-checks.yml": "consumer template\n",
163
+ });
164
+ await expect(installTemplate(repositoryPath, "agentics-checks", { sourcePath })).resolves.toEqual({
165
+ installed: [],
166
+ conflicts: [".github/workflows/agentics-checks.yml"],
167
+ });
168
+ await expect(installTemplate(repositoryPath, "agentics-checks", { force: true, sourcePath })).resolves.toMatchObject({
169
+ installed: [".github/workflows/agentics-checks.yml"],
170
+ });
80
171
  });
81
172
  });
82
173
  async function createDirectory(files) {
83
- const directory = await mkdtemp(join(tmpdir(), "platform-workflows-"));
174
+ const directory = await mkdtemp(join(tmpdir(), "workflows-"));
84
175
  temporaryDirectories.push(directory);
85
176
  await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
86
177
  const path = join(directory, relativePath);
@@ -90,3 +181,6 @@ async function createDirectory(files) {
90
181
  }));
91
182
  return directory;
92
183
  }
184
+ function escapeRegularExpression(value) {
185
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ }
@@ -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 {};
@@ -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,49 @@
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 { initializeRepository, readManifest, repositoryConfigRelativePath } from "./repository-state.js";
3
+ import { installCatalog, installTemplate, isTemplateName } from "./catalog-installation.js";
4
+ import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
5
5
  import { resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ const HELP_TEXT = `Workflows CLI — install and manage Plain Concepts Platform agentic workflows.
8
+
9
+ Usage: workflows <command> [options]
10
+
11
+ Commands:
12
+ init Inspect the repository and report its stack and visibility.
13
+ add Install package-owned workflow files into .github/.
14
+ update Alias for add. Use --force to overwrite managed files.
15
+ status Print repository inspection as JSON.
16
+ list List all available workflows and templates with install status.
17
+ search <query> Filter workflows and templates by name or description.
18
+
19
+ Options:
20
+ --visibility public|private Override repository visibility (init only).
21
+ --template <name> Install a standalone template instead of the catalog.
22
+ Templates: agentics-checks, agentics-maintenance,
23
+ app-ci-dotnet-next, app-ci-node-monorepo,
24
+ opencode.ci.json.
25
+ --force Overwrite managed files that differ from the package source.
26
+ -h, --help Show this help text.
27
+
28
+ Installed workflows are marked [x] when the corresponding .github/workflows/agent-*.md
29
+ file exists relative to the current directory.`;
7
30
  export async function run(arguments_, repositoryPath = process.cwd()) {
8
31
  const [command, ...options] = arguments_;
9
32
  if (command === "--help" || command === "-h" || command === undefined) {
10
- console.log("Usage: platform-workflows <init|add|update|status> [--visibility public|private] [--force]");
33
+ console.log(HELP_TEXT);
34
+ return 0;
35
+ }
36
+ if (command === "list") {
37
+ const entries = await listCatalog({ installedPath: repositoryPath });
38
+ console.log(formatCatalog(entries));
39
+ return 0;
40
+ }
41
+ if (command === "search") {
42
+ if (options.length === 0 || options.length > 1)
43
+ return fail("search requires exactly one query argument.");
44
+ const allEntries = await listCatalog({ installedPath: repositoryPath });
45
+ const results = searchCatalog(allEntries, options[0]);
46
+ console.log(formatCatalog(results));
11
47
  return 0;
12
48
  }
13
49
  if (command === "init") {
@@ -16,21 +52,23 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
16
52
  return fail("--visibility must be public or private.");
17
53
  const inspection = await inspectRepository(repositoryPath);
18
54
  const resolvedVisibility = await resolveVisibility(repositoryPath, visibility);
19
- const result = await initializeRepository(inspection, resolvedVisibility);
20
- console.log(JSON.stringify({ command, ...result }, null, 2));
55
+ console.log(JSON.stringify({ command, inspection, visibility: resolvedVisibility }, null, 2));
21
56
  return 0;
22
57
  }
23
58
  if (command === "status") {
24
59
  const inspection = await inspectRepository(repositoryPath);
25
- const manifest = await readManifest(repositoryPath);
26
- console.log(JSON.stringify({ command, inspection, manifest, repositoryConfigPath: repositoryConfigRelativePath }, null, 2));
60
+ console.log(JSON.stringify({ command, inspection }, null, 2));
27
61
  return 0;
28
62
  }
29
63
  if (command === "add" || command === "update") {
30
- if (options.some((option) => option !== "--force"))
31
- return fail(`${command} accepts only --force.`);
32
- const result = await installCatalog(repositoryPath, { force: options.includes("--force") });
33
- if (result.conflicts.length > 0 && !options.includes("--force")) {
64
+ const template = readTemplateOption(options);
65
+ if (template === "invalid")
66
+ return fail(`${command} accepts only --force or --template agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json.`);
67
+ const force = options.includes("--force");
68
+ const result = template === undefined
69
+ ? await installCatalog(repositoryPath, { force })
70
+ : await installTemplate(repositoryPath, template, { force });
71
+ if (result.conflicts.length > 0 && !force) {
34
72
  console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${result.conflicts.join("\n")}`);
35
73
  return 1;
36
74
  }
@@ -46,6 +84,19 @@ function readVisibilityOption(options) {
46
84
  return "invalid";
47
85
  return parseVisibility(options[1]) ?? "invalid";
48
86
  }
87
+ function readTemplateOption(options) {
88
+ const templateIndex = options.indexOf("--template");
89
+ if (templateIndex === -1)
90
+ return options.every((option) => option === "--force") ? undefined : "invalid";
91
+ if (templateIndex + 1 >= options.length || options.filter((option) => option === "--template").length !== 1)
92
+ return "invalid";
93
+ if (options.some((option, index) => option !== "--force" && index !== templateIndex && index !== templateIndex + 1))
94
+ return "invalid";
95
+ return templateNameFromOptions(options[templateIndex + 1]);
96
+ }
97
+ function templateNameFromOptions(value) {
98
+ return value !== undefined && isTemplateName(value) ? value : "invalid";
99
+ }
49
100
  function fail(message) {
50
101
  console.error(message);
51
102
  return 1;
@@ -0,0 +1 @@
1
+ export {};