diff --git a/CHANGELOG.md b/CHANGELOG.md index 555665f..b634209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Kiro is a supported tool profile.** `--tool kiro` writes artifacts under + `.kiro/`, uses root `AGENTS.md` for course rules, and is auto-detected from + `.kiro/steering/`, `.kiro/specs/`, `.kiro/hooks/`, `.kiro/settings/` or a bare + `.kiro/` directory. Content is + delivered in the generic variant. - **`10x sync` — bulk download & update with change visibility.** One command to download every unlocked lesson (`--all`) or refresh the ones you've already downloaded (default), with a report of what changed upstream. Each not-updated diff --git a/README.md b/README.md index 5223d4e..54d6f7c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Once your release includes it, run from the intended project directory: This installs **both** `10x-cli-setup` and `10x-cli-guide`, each with its complete `SKILL.md` and `references/compatibility.md`, into `.github/skills/`. Use -`--tool claude-code`, `cursor`, `codex`, `devin-desktop`, `gemini`, or `generic` +`--tool claude-code`, `cursor`, `codex`, `devin-desktop`, `gemini`, `kiro`, or `generic` when that is your intended tool. The target must be explicit; installation is project-only. There is no `--global`, automatic agent detection, `skills`/npx subprocess, authentication, or network access. The helper bytes come from the @@ -175,7 +175,7 @@ remain visible. Full lesson downloads and other commands remain available below. | Flag | Description | |------|-------------| -| `--tool ` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `generic` | +| `--tool ` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `kiro`, `generic` | | `--print` | Output artifact content to stdout instead of writing files | | `--type ` | Filter by artifact type: `skills`, `prompts`, `rules`, `configs` | | `--name ` | Filter by artifact name (requires `--type`) | @@ -302,7 +302,7 @@ scoring live in the template and the instance. | Flag | Description | |------|-------------| | `--template-version ` | Template tag to install (default: latest) | -| `--tool ` | Agent tool for skill placement (`claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `generic`) | +| `--tool ` | Agent tool for skill placement (`claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `kiro`, `generic`) | | `--yes` | Run non-interactively, accepting defaults | ```bash @@ -346,6 +346,7 @@ On first run, the CLI prompts you to choose your AI coding tool. Artifacts are w | Codex CLI | `.agents/` | `AGENTS.md` | | Devin Desktop | `.devin/` | `AGENTS.md` | | Gemini CLI | `.gemini/` | `GEMINI.md` | +| Kiro | `.kiro/` | `AGENTS.md` | | Generic | `.ai/` | `AGENTS.md` | Override with `--tool `. Validated writing commands save your choice in `~/.config/10x-cli/config.json`. Previews leave it unchanged. diff --git a/docs/reference/platform-support.md b/docs/reference/platform-support.md index 72163a7..a8f919a 100644 --- a/docs/reference/platform-support.md +++ b/docs/reference/platform-support.md @@ -63,6 +63,7 @@ The CLI writes artifacts to the correct directory for your AI coding tool: | GitHub Copilot | `.github/skills/` | `.github/copilot-instructions.md` | `.github/config-templates/` | | Codex CLI | `.agents/skills/` | `AGENTS.md` | `.agents/config-templates/` | | Devin Desktop | `.devin/skills/` | `AGENTS.md` | `.devin/config-templates/` | +| Kiro | `.kiro/skills/` | `AGENTS.md` | `.kiro/config-templates/` | | Generic | `.ai/skills/` | `AGENTS.md` | `.ai/config-templates/` | The CLI auto-detects your tool from project markers on first run. Override anytime with `--tool`: @@ -71,6 +72,23 @@ The CLI auto-detects your tool from project markers on first run. Override anyti 10x get m1l1 --tool cursor ``` +Kiro reads the root `AGENTS.md` as steering context, so it writes to the same +file as Codex CLI, Devin Desktop and Generic. Two profiles can hold the same +sentinel block only when their course-rules content is byte-identical — for Kiro +that means Generic. Installing Kiro alongside Codex CLI or Devin Desktop in one +project leaves the first owner's block untouched and reports the rules as +`conflict: skipped (incompatible_shared_owner)`; every other artifact still +installs normally. Pick one of those profiles per project, or accept that only +the first one to run keeps the course rules. + +Kiro is detected from `.kiro/steering/`, `.kiro/specs/`, `.kiro/hooks/`, +`.kiro/settings/` or a bare `.kiro/` directory. + +`.kiro/prompts/` and `.kiro/config-templates/` are staging locations the CLI +manages but Kiro does not read automatically — move any artifact you want Kiro +to pick up to the place Kiro expects it (`.kiro/steering/` for always-on +context, `.kiro/hooks/` for hooks). + `windsurf` remains a backward-compatible alias for `devin-desktop`. New files use Devin Desktop's `.devin/` workspace convention; legacy `.windsurf/` markers are still detected so existing 10x artifacts can be migrated. diff --git a/src/commands/get.ts b/src/commands/get.ts index 88c4231..56703a7 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -62,7 +62,7 @@ export function registerGetCommand(cli: CAC): void { .option("--course ", "Select course ID or slug (default: project edition or API recommendation)") .option( "--tool ", - "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)", + "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, kiro, generic)", ) .option("--print", "Print artifact content to stdout instead of writing to files") .option("--type ", "Artifact type filter: skills, prompts, rules, configs") diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 008e8b6..47489b8 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -101,7 +101,7 @@ export function registerSyncCommand(cli: CAC): void { .option("--course ", "Select course ID or slug (default: project edition or API recommendation)") .option( "--tool ", - "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)", + "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, kiro, generic)", ) .option("--lang ", "Content language: en (default) or pl") .option( diff --git a/src/lib/tool-detect.ts b/src/lib/tool-detect.ts index 2efea64..3112c4a 100644 --- a/src/lib/tool-detect.ts +++ b/src/lib/tool-detect.ts @@ -4,7 +4,8 @@ * of signals. The interactive prompt uses the top signal to pre-fill * `initialValue`; the user still confirms before anything is saved. * - * Pure file-system read; ≤12 `existsSync` calls, no I/O beyond that. + * Pure file-system read; a bounded number of `existsSync` calls, no I/O + * beyond that. */ import { existsSync } from "node:fs"; @@ -137,6 +138,31 @@ export function detectTools(projectRoot: string): DetectionSignal[] { signals.push({ profileId: "gemini", confidence: "medium", reason: ".gemini/ directory" }); } + // Kiro — .kiro/ holds steering, specs, hooks, settings and skills. The + // directory name is Kiro-specific enough that even a bare .kiro/ is a strong + // signal; the sub-marker branches exist only to give a precise hint reason. + if (hit(".kiro/" + MANIFEST_FILENAME)) { + signals.push({ + profileId: "kiro", + confidence: "strong", + reason: ".kiro/.10x-cli-manifest.json", + }); + } else if (hit(".kiro/steering") || hit(".kiro/specs")) { + signals.push({ + profileId: "kiro", + confidence: "strong", + reason: ".kiro/steering/ or .kiro/specs/", + }); + } else if (hit(".kiro/hooks") || hit(".kiro/settings")) { + signals.push({ + profileId: "kiro", + confidence: "strong", + reason: ".kiro/hooks/ or .kiro/settings/", + }); + } else if (hit(".kiro")) { + signals.push({ profileId: "kiro", confidence: "strong", reason: ".kiro/ directory" }); + } + // Generic — .ai/ is a project-defined convention if (hit(".ai/" + MANIFEST_FILENAME)) { signals.push({ @@ -161,6 +187,7 @@ const PROFILE_ORDER = [ "codex", "devin-desktop", "gemini", + "kiro", "generic", ]; diff --git a/src/lib/tool-profile.ts b/src/lib/tool-profile.ts index 4657c19..a1ea55e 100644 --- a/src/lib/tool-profile.ts +++ b/src/lib/tool-profile.ts @@ -101,6 +101,23 @@ export const PROFILES: Record = { sentinelBegin: SENTINEL_BEGIN, sentinelEnd: SENTINEL_END, }, + kiro: { + toolId: "kiro", + // No `kiro` transform exists in the delivery API; the generic variant is + // the agreed content representation until one does. + contentToolId: "generic", + displayName: "Kiro", + skillPath: (n) => `.kiro/skills/${n}/SKILL.md`, + skillDir: (n) => `.kiro/skills/${n}`, + promptPath: (n) => `.kiro/prompts/${n}.md`, + configPath: (n) => `.kiro/config-templates/${n}`, + // Kiro loads AGENTS.md as steering context, so it co-owns the root file + // with codex, devin-desktop and generic. + rulesFile: "AGENTS.md", + manifestDir: ".kiro", + sentinelBegin: SENTINEL_BEGIN, + sentinelEnd: SENTINEL_END, + }, generic: { toolId: "generic", displayName: "Other / Generic", diff --git a/tests/helpers/helper-install-contract.ts b/tests/helpers/helper-install-contract.ts index 44d6ee7..451ee9e 100644 --- a/tests/helpers/helper-install-contract.ts +++ b/tests/helpers/helper-install-contract.ts @@ -59,13 +59,24 @@ export function helperInstallContract(label: string, command: () => string[]): v expect(tree(f.home)).toEqual({}); }); - it("previews the chosen profile without writes", () => { - const f = fixture(); const before = tree(f.project); - const p = f.run("helpers", "install", "--tool", "claude-code", "--dry-run"); + for (const [tool, dir] of [["claude-code", ".claude"], ["kiro", ".kiro"]] as const) { + it(`previews the chosen ${tool} profile without writes`, () => { + const f = fixture(); const before = tree(f.project); + const p = f.run("helpers", "install", "--tool", tool, "--dry-run"); + expect(p.exitCode).toBe(0); + const files = JSON.parse(p.stdout.toString()).data.files; + expect(files.length).toBeGreaterThan(0); + expect(files.every((x: { path: string; action: string }) => x.path.startsWith(dir) && x.action === "would_create")).toBe(true); + expect(tree(f.project)).toEqual(before); + }); + } + + it("installs the complete packaged tree for kiro under .kiro/skills", () => { + const f = fixture(); + const p = f.run("helpers", "install", "--tool", "kiro"); expect(p.exitCode).toBe(0); - const files = JSON.parse(p.stdout.toString()).data.files; - expect(files.every((x: { path: string; action: string }) => x.path.startsWith(".claude") && x.action === "would_create")).toBe(true); - expect(tree(f.project)).toEqual(before); + expect(tree(join(f.project, ".kiro/skills"))).toEqual(tree(SOURCE)); + expect(existsSync(join(f.project, ".claude"))).toBe(false); }); it("preserves local edits in either helper and returns nonzero before installing the other", () => { diff --git a/tests/tool-detect.test.ts b/tests/tool-detect.test.ts index fed174a..0c7f0eb 100644 --- a/tests/tool-detect.test.ts +++ b/tests/tool-detect.test.ts @@ -175,6 +175,79 @@ describe("detectTools", () => { expect(signals[0]!.profileId).toBe("devin-desktop"); expect(signals[0]!.confidence).toBe("strong"); }); + + it(".kiro manifest → kiro (strong)", () => { + writeManifestAt(".kiro"); + const signals = detectTools(tmp); + expect(signals).toHaveLength(1); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + }); + + it(".kiro/steering/ → kiro (strong)", () => { + touchDir(".kiro/steering"); + const signals = detectTools(tmp); + expect(signals).toHaveLength(1); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + }); + + it(".kiro/specs/ → kiro (strong)", () => { + touchDir(".kiro/specs"); + const signals = detectTools(tmp); + expect(signals).toHaveLength(1); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + }); + + it(".kiro/hooks/ → kiro (strong)", () => { + touchDir(".kiro/hooks"); + const signals = detectTools(tmp); + expect(signals).toHaveLength(1); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + expect(signals[0]!.reason).toBe(".kiro/hooks/ or .kiro/settings/"); + }); + + it(".kiro/settings/ → kiro (strong)", () => { + touchDir(".kiro/settings"); + const signals = detectTools(tmp); + expect(signals).toHaveLength(1); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + expect(signals[0]!.reason).toBe(".kiro/hooks/ or .kiro/settings/"); + }); + + it("bare .kiro/ directory → kiro (strong)", () => { + touchDir(".kiro"); + const signals = detectTools(tmp); + expect(signals).toHaveLength(1); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + expect(signals[0]!.reason).toBe(".kiro/ directory"); + }); + + it(".kiro/steering/ + AGENTS.md → kiro (strong) outranks codex (medium)", () => { + touchDir(".kiro/steering"); + touchFile("AGENTS.md", "# agents\n"); + const signals = detectTools(tmp); + expect(signals[0]!.profileId).toBe("kiro"); + expect(signals[0]!.confidence).toBe("strong"); + expect(signals.slice(1).map((s) => s.profileId)).toEqual(["codex", "generic"]); + }); + + it("bare .kiro/ + AGENTS.md → kiro outranks codex on confidence", () => { + // `.kiro/` is Kiro-specific, so it never degrades to `medium` and cannot + // lose a PROFILE_ORDER tie to codex's AGENTS.md signal. + touchDir(".kiro"); + touchFile("AGENTS.md", "# agents\n"); + const signals = detectTools(tmp); + expect(signals.map((s) => [s.profileId, s.confidence])).toEqual([ + ["kiro", "strong"], + ["codex", "medium"], + ["generic", "weak"], + ]); + }); }); describe("topDetectedProfile", () => { @@ -188,4 +261,11 @@ describe("topDetectedProfile", () => { const profile = topDetectedProfile(signals); expect(profile?.toolId).toBe("cursor"); }); + + it("resolves the Kiro profile from a .kiro/ signal", () => { + touchDir(".kiro/steering"); + expect(topDetectedProfile(detectTools(tmp))?.toolId).toBe("kiro"); + rmSync(join(tmp, ".kiro/steering"), { recursive: true, force: true }); + expect(topDetectedProfile(detectTools(tmp))?.toolId).toBe("kiro"); + }); }); diff --git a/tests/tool-profile.test.ts b/tests/tool-profile.test.ts index c34fe27..5ade511 100644 --- a/tests/tool-profile.test.ts +++ b/tests/tool-profile.test.ts @@ -13,6 +13,7 @@ import { PROFILES, SENTINEL_BEGIN, SENTINEL_END, + contentToolId, } from "../src/lib/tool-profile"; import { readToolConfig, saveToolConfig, toolConfigPath } from "../src/lib/config"; import { prepareToolForWrite, resolveToolProfile } from "../src/lib/tool-prompt"; @@ -26,7 +27,7 @@ import { clackMockState, resetClackMock, type SelectOpts } from "./helpers/clack import { redirectConfigDir, restoreConfigDir } from "./helpers/config-isolation"; // --------------------------------------------------------------------------- -// Profile path tests — each of the 5 tool profiles +// Profile path tests — one case per tool profile // --------------------------------------------------------------------------- describe("tool profiles — path generation", () => { @@ -76,6 +77,17 @@ describe("tool profiles — path generation", () => { expect(p.manifestDir).toBe(".devin"); }); + it("kiro profile produces .kiro/ paths and requests the generic content variant", () => { + const p = PROFILES["kiro"]!; + expect(p.displayName).toBe("Kiro"); + expect(p.skillPath("code-review")).toBe(".kiro/skills/code-review/SKILL.md"); + expect(p.promptPath("plan")).toBe(".kiro/prompts/plan.md"); + expect(p.configPath("settings.json")).toBe(".kiro/config-templates/settings.json"); + expect(p.rulesFile).toBe("AGENTS.md"); + expect(p.manifestDir).toBe(".kiro"); + expect(contentToolId(p)).toBe("generic"); + }); + it("generic profile produces .ai/ paths", () => { const p = PROFILES["generic"]!; expect(p.skillPath("code-review")).toBe(".ai/skills/code-review/SKILL.md"); diff --git a/tests/tool-switch.test.ts b/tests/tool-switch.test.ts index dd26996..6622e97 100644 --- a/tests/tool-switch.test.ts +++ b/tests/tool-switch.test.ts @@ -561,6 +561,50 @@ describe("profile migration shared rules", () => { expect(readFileSync(join(tmp, "AGENTS.md"))).toEqual(before); expect(readManifest(join(tmp, ".ai"))!.managedRules).toBeDefined(); }); + it("transfers AGENTS.md ownership from kiro to codex without rewriting the file", async () => { + const { applyBundle, findOrphanedManifests } = await import("../src/lib/writer"); + const source = PROFILES.kiro!; + const destination = PROFILES.codex!; + const bundle = { lessonId: "m1l1", module: 1, lesson: 1, title: "A", summary: "", skills: [{ name: "a", files: [{ path: "SKILL.md", content: "A" }] }], prompts: [], configs: [], rules: [{ name: "rules", content: "shared" }] }; + await applyBundle(bundle, tmp, { profile: source }); + const before = readFileSync(join(tmp, "AGENTS.md")); + expect(before.toString()).toContain(SENTINEL_BEGIN); + const orphan = findOrphanedManifests(tmp, destination).find((entry) => entry.profile.toolId === "kiro")!; + const result = migrateArtifacts(tmp, orphan, destination); + expect(result.sentinelStripped).toBe(false); + expect(readFileSync(join(tmp, "AGENTS.md"))).toEqual(before); + expect(readManifest(join(tmp, ".kiro"))).toBeNull(); + expect(readManifest(join(tmp, ".agents"))!.managedRules?.upstreamHash).toBe(orphan.manifest.managedRules?.upstreamHash); + expect(existsSync(join(tmp, destination.skillPath("a")))).toBe(true); + }); + it("blocks kiro from rewriting a codex-owned AGENTS.md block when the rules bytes differ", async () => { + // The migration/cleanup cases above deliberately use one shared body so the + // upstream hashes match. In production the co-owners request different + // content transforms (codex vs generic), so the hashes diverge and + // planManagedRules must fail closed rather than clobber the other owner. + const { applyBundle } = await import("../src/lib/writer"); + const base = { lessonId: "m1l1", module: 1, lesson: 1, title: "A", summary: "", skills: [], prompts: [], configs: [] }; + await applyBundle({ ...base, rules: [{ name: "rules", content: "codex variant" }] }, tmp, { profile: PROFILES.codex! }); + const before = readFileSync(join(tmp, "AGENTS.md"), "utf8"); + const result = await applyBundle({ ...base, rules: [{ name: "rules", content: "kiro variant" }] }, tmp, { profile: PROFILES.kiro!, onConflict: async () => "overwrite" }); + expect(result.rules.action).toBe("conflict_skipped"); + expect(result.rules.reason).toBe("incompatible_shared_owner"); + expect(readFileSync(join(tmp, "AGENTS.md"), "utf8")).toBe(before); + expect(readManifest(join(tmp, ".kiro"))?.managedRules).toBeUndefined(); + expect(readManifest(join(tmp, ".agents"))!.managedRules).toBeDefined(); + }); + it("cleanup releases the kiro owner while codex keeps the shared AGENTS.md block", async () => { + const { applyBundle, findOrphanedManifests } = await import("../src/lib/writer"); + const bundle = { lessonId: "m1l1", module: 1, lesson: 1, title: "A", summary: "", skills: [], prompts: [], configs: [], rules: [{ name: "rules", content: "shared" }] }; + await applyBundle(bundle, tmp, { profile: PROFILES.kiro! }); + await applyBundle(bundle, tmp, { profile: PROFILES.codex!, onConflict: async () => "overwrite" }); + const before = readFileSync(join(tmp, "AGENTS.md")); + const orphan = findOrphanedManifests(tmp, PROFILES.codex!).find((entry) => entry.profile.toolId === "kiro")!; + const result = deleteArtifacts(tmp, orphan); + expect(result.sentinelStripped).toBe(false); + expect(readFileSync(join(tmp, "AGENTS.md"))).toEqual(before); + expect(readManifest(join(tmp, ".agents"))!.managedRules).toBeDefined(); + }); it("retains unresolved source rules and their ledger instead of adopting a local edit", () => { const text = `${SENTINEL_BEGIN}\n\ntrusted\n\n${SENTINEL_END}\n`; const orphan = seedOrphan({ skills: ["a"], rulesFileContent: text }); diff --git a/tests/writer-profiles.test.ts b/tests/writer-profiles.test.ts index 0a5ae39..136c924 100644 --- a/tests/writer-profiles.test.ts +++ b/tests/writer-profiles.test.ts @@ -152,6 +152,27 @@ describe("writer with Devin Desktop profile", () => { }); }); +// --------------------------------------------------------------------------- +// Kiro profile +// --------------------------------------------------------------------------- + +describe("writer with kiro profile", () => { + const kiroProfile = PROFILES["kiro"]!; + + it("writes artifacts under .kiro/ and steering rules to AGENTS.md", async () => { + await applyBundle(makeBundle(), tmp, { profile: kiroProfile }); + expect(existsSync(join(tmp, ".kiro/skills/code-review/SKILL.md"))).toBe(true); + expect(existsSync(join(tmp, ".kiro/prompts/plan.md"))).toBe(true); + expect(existsSync(join(tmp, ".kiro/config-templates/settings.json"))).toBe(true); + const rules = readFileSync(join(tmp, "AGENTS.md"), "utf8"); + expect(rules).toContain("Always test."); + expect(rules).toContain(""); + // Freshness keys on toolId, not contentToolId, so the manifest stays "kiro". + expect(readManifest(join(tmp, ".kiro"))?.tool).toBe("kiro"); + expect(existsSync(join(tmp, ".ai"))).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // Generic profile // ---------------------------------------------------------------------------