diff --git a/.gitignore b/.gitignore index 082fc2b..1f4a42a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,19 @@ Thumbs.db # Dependencies node_modules/ .pnpm-store/ + +# Reference folder +tsumiki-qwen3-coder-support/ + +# Test files (for verification only) +**/__tests__/ +**/__test__/ +*.test.ts +*.test.tsx +*.test.js +*.test.jsx +test-*.md +tdd-test-plan.md package-lock.json yarn.lock pnpm-lock.yaml diff --git a/.tsumikirc b/.tsumikirc new file mode 100644 index 0000000..16a6b48 --- /dev/null +++ b/.tsumikirc @@ -0,0 +1 @@ +target = "qwen" diff --git a/README.md b/README.md index b02dd2a..79b542d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,40 @@ Tsumikiを使用するには、次のnpxコマンドでインストールして npx tsumiki install ``` -このコマンドを実行すると、`.claude/commands/` にTsumikiのClaude Codeスラッシュコマンドがインストールされます。 +デフォルトでは、`.claude/commands/` にTsumikiのClaude Codeスラッシュコマンドがインストールされます。 +Qwen3 Coder を使用する場合は、以下のように `--target` オプションを指定してください: + +```bash +npx tsumiki install --target qwen +``` + +または、設定ファイル (`.tsumikirc`) を使用してデフォルトのターゲットを指定することもできます。 + +## アンインストール + +Tsumikiをアンインストールするには、次のコマンドを使用してください: + +```bash +npx tsumiki uninstall +``` + +Qwen3 Coder環境からアンインストールする場合は、`--target` オプションを指定してください: + +```bash +npx tsumiki uninstall --target qwen +``` + +設定ファイル(`.tsumikirc`)でターゲットを指定している場合は、オプションなしでも適切な環境からアンインストールされます。 + +### 設定ファイル + +プロジェクトのルートディレクトリに `.tsumikirc` ファイルを作成し、以下のように記述することで、 +コマンドラインオプションを指定しなくても、指定したCoder環境にインストールできます。 + +例: `.tsumikirc` +```toml +target = "qwen" +``` ## 概要 diff --git a/package.json b/package.json index b5d399a..0ba28a9 100644 --- a/package.json +++ b/package.json @@ -40,12 +40,15 @@ "dist" ], "scripts": { - "build": "rm -rf dist && mkdir -p dist/commands dist/agents && cp ./commands/*.md ./commands/*.sh dist/commands/ 2>/dev/null || true && cp ./agents/*.md dist/agents/ 2>/dev/null || true && tsup", + "build": "rimraf dist && mkdirp dist/commands && copyfiles -u 1 commands/*.md commands/*.sh dist/commands/ && tsup", "build:run": "pnpm build && node dist/cli.js", "check": "biome check src", "fix": "biome check src --write", "prepare": "simple-git-hooks", "secretlint": "secretlint --secretlintignore .gitignore **/*", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", "typecheck": "tsgo --noEmit" }, "simple-git-hooks": { @@ -62,11 +65,17 @@ "@secretlint/secretlint-rule-preset-recommend": "10.2.1", "@tsconfig/node24": "24.0.1", "@types/fs-extra": "11.0.4", + "@types/jest": "30.0.0", "@types/node": "24.1.0", "@types/react": "19.1.9", "@typescript/native-preview": "7.0.0-dev.20250729.2", + "copyfiles": "2.4.1", + "jest": "30.0.5", + "mkdirp": "3.0.1", + "rimraf": "6.0.1", "secretlint": "10.2.1", "simple-git-hooks": "2.13.0", + "ts-jest": "29.4.1", "tsup": "8.5.0", "tsx": "4.20.3", "typescript": "5.8.3" diff --git a/src/cli.ts b/src/cli.ts index e3653fb..5e41d02 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,18 +9,26 @@ const program = new Command(); program .name("tsumiki") - .description("CLI tool for installing Claude Code command templates") + .description( + "CLI tool for installing Tsumiki commands for various Coder environments", + ) .version("1.0.0"); program .command("install") - .description("Install Claude Code command templates to .claude/commands/") - .action(installCommand); + .description( + "Install Tsumiki command templates to the specified Coder environment", + ) + .option("-t, --target ", "Target Coder environment (claude or qwen)") + .action((options) => installCommand(options.target)); program .command("uninstall") - .description("Uninstall Claude Code command templates from .claude/commands/") - .action(uninstallCommand); + .description( + "Uninstall Tsumiki command templates from the specified Coder environment", + ) + .option("-t, --target ", "Target Coder environment (claude or qwen)") + .action((options) => uninstallCommand(options.target)); program .command("gitignore") diff --git a/src/commands/gitignore.tsx b/src/commands/gitignore.tsx index 3ddf50b..82dc482 100644 --- a/src/commands/gitignore.tsx +++ b/src/commands/gitignore.tsx @@ -39,9 +39,7 @@ const GitignoreComponent: React.FC = () => { ); // 具体的なファイルパスをルールとして作成 - const rulesToAdd = targetFiles.map( - (file) => `.claude/commands/${file}`, - ); + const rulesToAdd = targetFiles.map((file) => `.qwen/commands/${file}`); let gitignoreContent = ""; let gitignoreExists = false; @@ -162,7 +160,7 @@ const GitignoreComponent: React.FC = () => { 既存のルール: {skippedRules.map((rule) => ( - • {rule} + {` • ${rule}`} ))} @@ -181,7 +179,7 @@ const GitignoreComponent: React.FC = () => { 追加されたルール ({addedRules.length}個): {addedRules.map((rule) => ( - • {rule} + {` • ${rule}`} ))} @@ -191,7 +189,7 @@ const GitignoreComponent: React.FC = () => { 既存のルール ({skippedRules.length}個): {skippedRules.map((rule) => ( - • {rule} + {` • ${rule}`} ))} diff --git a/src/commands/install.tsx b/src/commands/install.tsx index c75dca9..3c2b7a5 100644 --- a/src/commands/install.tsx +++ b/src/commands/install.tsx @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import fs from "fs-extra"; import { Box, Newline, render, Text } from "ink"; import React, { useEffect, useState } from "react"; +import { getTarget, getTargetDir } from "../utils/target.js"; // Import the new utility type InstallStatus = | "starting" @@ -11,67 +12,54 @@ type InstallStatus = | "completed" | "error"; -const InstallComponent: React.FC = () => { +interface InstallComponentProps { + target?: string; // Receive target as a prop +} + +const InstallComponent: React.FC = ({ + target: cliTarget, +}) => { + // Accept target prop const [status, setStatus] = useState("starting"); const [copiedFiles, setCopiedFiles] = useState([]); const [error, setError] = useState(null); + const [resolvedTarget, setResolvedTarget] = useState(""); // State to hold the resolved target useEffect(() => { const performInstall = async (): Promise => { try { - setStatus("checking"); + // Resolve the target (cliTarget -> config -> default) + const target = await getTarget(cliTarget); + setResolvedTarget(target); // Store the resolved target for display + const targetDir = getTargetDir(target); // Get the target directory path - // 現在のディレクトリを取得 - const currentDir = process.cwd(); - const commandsTargetDir = path.join(currentDir, ".claude", "commands"); - const agentsTargetDir = path.join(currentDir, ".claude", "agents"); + setStatus("checking"); - // tsumikiのcommandsディレクトリとagentsディレクトリを取得 + // tsumikiのcommandsディレクトリを取得 const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - // ビルド後はdist/commands, dist/agentsを参照(cli.jsがdist/にあるため) - const tsumikiCommandsDir = path.join(__dirname, "commands"); - const tsumikiAgentsDir = path.join(__dirname, "agents"); + // ビルド後はdist/commandsを参照(cli.jsがdist/にあるため) + const tsumikiDir = path.join(__dirname, "commands"); - // .claude/commandsと.claude/agentsディレクトリが存在しない場合は作成 - await fs.ensureDir(commandsTargetDir); - await fs.ensureDir(agentsTargetDir); + // ターゲットディレクトリが存在しない場合は作成 + await fs.ensureDir(targetDir); setStatus("copying"); // commandsディレクトリ内のすべての.mdファイルと.shファイルを取得 - const commandFiles = await fs.readdir(tsumikiCommandsDir); - const targetCommandFiles = commandFiles.filter( + const files = await fs.readdir(tsumikiDir); + const targetFiles = files.filter( (file) => file.endsWith(".md") || file.endsWith(".sh"), ); - // agentsディレクトリ内のすべての.mdファイルを取得 - let targetAgentFiles: string[] = []; - try { - const agentFiles = await fs.readdir(tsumikiAgentsDir); - targetAgentFiles = agentFiles.filter((file) => file.endsWith(".md")); - } catch { - // agentsディレクトリが存在しない場合はスキップ - } - const copiedFilesList: string[] = []; - // commandsファイルをコピー - for (const file of targetCommandFiles) { - const sourcePath = path.join(tsumikiCommandsDir, file); - const targetPath = path.join(commandsTargetDir, file); + for (const file of targetFiles) { + const sourcePath = path.join(tsumikiDir, file); + const targetPath = path.join(targetDir, file); await fs.copy(sourcePath, targetPath); - copiedFilesList.push(`commands/${file}`); - } - - // agentsファイルをコピー - for (const file of targetAgentFiles) { - const sourcePath = path.join(tsumikiAgentsDir, file); - const targetPath = path.join(agentsTargetDir, file); - - await fs.copy(sourcePath, targetPath); - copiedFilesList.push(`agents/${file}`); + copiedFilesList.push(file); } setCopiedFiles(copiedFilesList); @@ -94,7 +82,7 @@ const InstallComponent: React.FC = () => { }; performInstall(); - }, []); + }, [cliTarget]); // Add cliTarget as a dependency if (status === "starting") { return ( @@ -134,21 +122,26 @@ const InstallComponent: React.FC = () => { ✅ インストールが完了しました! + ターゲット: {resolvedTarget} + コピーされたファイル ({copiedFiles.length}個): {copiedFiles.map((file) => ( - {" "} - • {file} + {" • " + file} ))} - Claude Codeで以下のようにコマンドを使用できます: + {resolvedTarget === "claude" + ? "Claude Code" + : resolvedTarget === "qwen" + ? "Qwen Code" + : resolvedTarget} - /tdd-requirements - /kairo-design - @agent-symbol-searcher - ... + で以下のようにコマンドを使用できます: + {"/tdd-requirements"} + {"/kairo-design"} + {"..."} ); } @@ -156,6 +149,7 @@ const InstallComponent: React.FC = () => { return null; }; -export const installCommand = (): void => { - render(React.createElement(InstallComponent)); +// Modify the installCommand function to accept the target and pass it to the component +export const installCommand = (target?: string): void => { + render(React.createElement(InstallComponent, { target })); }; diff --git a/src/commands/uninstall.tsx b/src/commands/uninstall.tsx index d287c4d..3748693 100644 --- a/src/commands/uninstall.tsx +++ b/src/commands/uninstall.tsx @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import fs from "fs-extra"; import { Box, Newline, render, Text } from "ink"; import React, { useEffect, useState } from "react"; +import { getTarget, getTargetDir } from "../utils/target.js"; // Import the new utility type UninstallStatus = | "starting" @@ -12,21 +13,30 @@ type UninstallStatus = | "error" | "not_found"; -const UninstallComponent: React.FC = () => { +interface UninstallComponentProps { + target?: string; // Receive target as a prop +} + +const UninstallComponent: React.FC = ({ + target: cliTarget, +}) => { + // Accept target prop const [status, setStatus] = useState("starting"); const [removedFiles, setRemovedFiles] = useState([]); const [error, setError] = useState(null); + const [resolvedTarget, setResolvedTarget] = useState(""); // State to hold the resolved target useEffect(() => { const performUninstall = async (): Promise => { try { - setStatus("checking"); + // Resolve the target (cliTarget -> config -> default) + const target = await getTarget(cliTarget); + setResolvedTarget(target); // Store the resolved target for display + const targetDir = getTargetDir(target); // Get the target directory path - // 現在のディレクトリを取得 - const currentDir = process.cwd(); - const targetDir = path.join(currentDir, ".claude", "commands"); + setStatus("checking"); - // .claude/commandsディレクトリが存在するかチェック + // ターゲットディレクトリが存在するかチェック const dirExists = await fs.pathExists(targetDir); if (!dirExists) { setStatus("not_found"); @@ -50,7 +60,7 @@ const UninstallComponent: React.FC = () => { setStatus("removing"); - // .claude/commands内のファイルをチェックして、tsumiki由来のファイルのみ削除 + // ターゲットディレクトリ内のファイルをチェックして、tsumiki由来のファイルのみ削除 const installedFiles = await fs.readdir(targetDir); const removedFilesList: string[] = []; @@ -62,16 +72,16 @@ const UninstallComponent: React.FC = () => { } } - // 削除後に.claude/commandsディレクトリが空になったかチェック + // 削除後にターゲットディレクトリが空になったかチェック const remainingFiles = await fs.readdir(targetDir); if (remainingFiles.length === 0) { // 空のディレクトリを削除 await fs.rmdir(targetDir); - // .claudeディレクトリも空の場合は削除 - const claudeDir = path.dirname(targetDir); - const claudeFiles = await fs.readdir(claudeDir); - if (claudeFiles.length === 0) { - await fs.rmdir(claudeDir); + // 親ディレクトリ (.claude or .qwen) も空の場合は削除 + const parentDir = path.dirname(targetDir); + const parentFiles = await fs.readdir(parentDir); + if (parentFiles.length === 0) { + await fs.rmdir(parentDir); } } @@ -95,7 +105,7 @@ const UninstallComponent: React.FC = () => { }; performUninstall(); - }, []); + }, [cliTarget]); // Add cliTarget as a dependency if (status === "starting") { return ( @@ -125,7 +135,8 @@ const UninstallComponent: React.FC = () => { return ( - ⚠️ .claude/commands ディレクトリが見つかりません + ⚠️ {getTargetDir(resolvedTarget).replace(process.cwd(), "").slice(1)}{" "} + ディレクトリが見つかりません Tsumikiはインストールされていないようです。 @@ -157,16 +168,23 @@ const UninstallComponent: React.FC = () => { ✅ アンインストールが完了しました! + ターゲット: {resolvedTarget} + 削除されたファイル ({removedFiles.length}個): {removedFiles.map((file) => ( - {" "} - • {file} + {" • " + file} ))} - TsumikiのClaude Codeコマンドテンプレートが削除されました。 + Tsumikiの + {resolvedTarget === "claude" + ? "Claude Code" + : resolvedTarget === "qwen" + ? "Qwen Code" + : resolvedTarget} + コマンドテンプレートが削除されました。 ); @@ -175,6 +193,7 @@ const UninstallComponent: React.FC = () => { return null; }; -export const uninstallCommand = (): void => { - render(React.createElement(UninstallComponent)); +// Modify the uninstallCommand function to accept the target and pass it to the component +export const uninstallCommand = (target?: string): void => { + render(React.createElement(UninstallComponent, { target })); }; diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..4e7e642 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,34 @@ +import * as path from "node:path"; +import fs from "fs-extra"; + +export type Config = { + target?: string; +}; + +/** + * 設定ファイルから設定を読み込みます。 + * @returns 設定オブジェクト。ファイルが存在しない場合は空のオブジェクトを返します。 + */ +export async function loadConfig(): Promise { + const configPath = path.join(process.cwd(), ".tsumikirc"); + if (await fs.pathExists(configPath)) { + try { + // .tsumikirc は TOML 形式と仮定 + const configFileContent = await fs.readFile(configPath, "utf-8"); + // シンプルなパーサー: `key = value` 形式のみ対応 + const config: Config = {}; + configFileContent.split("\n").forEach((line) => { + const [key, value] = line.split("=").map((part) => part.trim()); + if (key && value) { + // Remove quotes from value if present + config[key as keyof Config] = value.replace(/^["']|["']$/g, ""); + } + }); + return config; + } catch (err) { + console.error("Warning: Could not read .tsumikirc file:", err); + return {}; + } + } + return {}; +} diff --git a/src/utils/target.ts b/src/utils/target.ts new file mode 100644 index 0000000..f40564e --- /dev/null +++ b/src/utils/target.ts @@ -0,0 +1,54 @@ +import * as path from "node:path"; +import { type Config, loadConfig } from "./config.js"; + +/** + * ターゲットとなるCoder環境を取得します。 + * 優先順位: コマンドラインオプション > 設定ファイル > デフォルト + * @param cliTarget コマンドラインオプションで指定されたターゲット + * @returns ターゲットとなるCoder環境 ('claude' or 'qwen') + */ +export async function getTarget( + cliTarget: string | undefined, +): Promise { + // 1. コマンドラインオプションが指定されていればそれを使用 + if (cliTarget) { + return cliTarget; + } + + // 2. 設定ファイルから読み込み + const config: Config = await loadConfig(); + if (config.target) { + return config.target; + } + + // 3. デフォルトはclaude + return "claude"; +} + +/** + * ターゲットに対応するディレクトリパスを取得します。 + * @param target ターゲット ('claude' or 'qwen' or その他) + * @returns ディレクトリパス + */ +export function getTargetDir(target: string): string { + const currentDir = process.cwd(); + + switch (target) { + case "claude": + return path.join(currentDir, ".claude", "commands"); + case "qwen": + return path.join(currentDir, ".qwen", "commands"); + default: + // ここでは、指定された名前をそのままディレクトリ名として使用 (例: '.mycoder/commands') + // ただし、'..' や '/' などの不正な文字列は避けるべき + // 簡易的な検証を追加 + if ( + target.includes("..") || + target.includes("/") || + target.includes("\\") + ) { + throw new Error(`Invalid target name: ${target}`); + } + return path.join(currentDir, `.${target}`, "commands"); + } +} diff --git a/tsconfig.json b/tsconfig.json index 7510be6..ae4fb63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,10 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "jsx": "react" + "jsx": "react", + "isolatedModules": true, + "types": ["node", "jest"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/__tests__/**/*", "src/**/*.test.ts", "src/**/*.test.tsx"] }