| name | add-viewmodel |
| description | This skill should be used when the user requests adding a new screen, dialog, page, window, or any UI component that needs a ViewModel in a WinUI 3 / WPF / MAUI project using CommunityToolkit.Mvvm. Triggers on phrases like "ViewModel 추가", "새 화면", "다이얼로그 추가", "페이지 만들기", "add screen/page/dialog/window". Generates ViewModel + View skeleton with proper MVVM bindings and DI registration. Do NOT trigger for non-XAML stacks (React/web, ASP.NET WebAPI controllers), simple UI text/label/style tweaks on an existing view, or debugging an existing ViewModel (use pjc-systematic-debugging). Android Jetpack ViewModel is out of scope. |
| argument-hint | <화면 이름 또는 목적> |
Add ViewModel
WinUI 3 / WPF / MAUI 프로젝트에 MVVM 패턴(CommunityToolkit.Mvvm)으로
View + ViewModel 스켈레톤을 추가한다.
호출 흐름
이 skill은 pjc:implement-task의 Phase I 안에서 호출되거나, 사용자가 직접 /pjc:add-viewmodel로 호출할 수 있다.
| 호출 방식 | 흐름 |
|---|
| implement-task Phase I 안 | plan.md task가 "ViewModel 추가" 패턴이면 자동 호출. 이 skill이 boilerplate 생성 후 implement-task의 Phase V가 검증을 이어받음. |
| 사용자 직접 호출 | plan.md 없이 단독 사용. 단, require-plan-for-write hook이 차단할 수 있으므로 $env:CLAUDE_HARNESS_QUICK = '1' 필요. |
이 skill의 책임 범위: ViewModel/View boilerplate, DI 등록, 기본 테스트 스켈레톤 생성까지.
책임 범위 밖: 비즈니스 로직, 데이터 바인딩 상세, 통합 검증 — implement-task가 담당.
Android의 Jetpack ViewModel은 비대상. Android의 경우 implement-task가 직접 구현.
사전 조건
이 skill을 호출하기 전에 plan-feature로 다음이 결정되어 있어야 한다:
- 화면 이름 (예:
Settings, UserDetail)
- 화면 종류 (Page / Window / UserControl / ContentDialog)
- 위치 (어느 모듈/프로젝트)
- 상위 네비게이션과의 연결 방식
- 필요한 의존성 서비스 (있다면)
위 정보가 없으면 사용자에게 묻거나 plan-feature로 복귀.
절대 규칙
- AGENTS.md 우선. 프로젝트가 다른 패턴(ReactiveUI, MVVM Light 등)을 명시했다면 이 skill을 사용하지 않는다.
- DDD 준수. ViewModel은 UI 레이어. 비즈니스 로직은 Domain 서비스를 호출하기만 한다.
- DI 등록 누락 금지. ViewModel은 반드시
ConfigureServices에 등록.
- 한글 주석. XML 문서 주석 포함 모두 한글.
- UTF-8 (BOM 없음).
- WinUI 3 프로젝트면 디자인·다국어 규칙 준수. AGENTS.md(winui3 템플릿)의 디자인 규칙(토큰화, 폰트 미지정, 시스템 키 우선)과 다국어 규칙(문구는
x:Uid+.resw, 하드코딩 금지)을 따른다. 상세는 docs/WINUI3-DESIGN-GUIDE.md가 있으면 참조. View의 문구를 코드/XAML에 직접 쓰지 않는다.
실행 단계
Step 1. 컨텍스트 파악
다음을 확인:
- 기존 ViewModel 위치 (예:
src/*/ViewModels/)
- 기존 View 위치 (예:
src/*/Views/)
- DI 등록 진입점 (보통
App.xaml.cs 또는 Program.cs의 ConfigureServices)
- 네비게이션 서비스 패턴 (
INavigationService 등 존재 여부)
- 기존 ViewModel 한 개를 읽어 컨벤션 파악 (네이밍, 베이스 클래스, 주석 스타일)
CommunityToolkit.Mvvm PackageReference 존재 확인(csproj grep) — 없으면 Halt(아래 Halt 조건이 정본: 의존성 추가는 승인 필요이므로 임의 추가·컴파일 불가 코드 생성 금지).
Step 2. ViewModel 생성
기본 템플릿:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace <ProjectNamespace>.ViewModels;
public sealed partial class <Name>ViewModel : ObservableObject
{
private readonly ILogger<<Name>ViewModel> _logger;
[ObservableProperty]
private string _title = "<기본 제목>";
[ObservableProperty]
private bool _isBusy;
public <Name>ViewModel(
ILogger<<Name>ViewModel> logger
)
{
_logger = logger;
}
[RelayCommand]
private async Task LoadAsync()
{
if (IsBusy) return;
try
{
IsBusy = true;
_logger.LogInformation("<Name> 화면 로딩 완료");
}
catch (Exception ex)
{
_logger.LogError(ex, "<Name> 화면 로딩 실패");
}
finally
{
IsBusy = false;
}
}
}
Step 3. View 생성
WinUI 3 Page 또는 Window (WPF는 아래 주석 참조)
XAML (<Name>Page.xaml):
<Page
x:Class="<ProjectNamespace>.Views.<Name>Page"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Padding="16" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0"
Text="{x:Bind ViewModel.Title, Mode=OneWay}"
Style="{StaticResource TitleTextBlockStyle}"/>
<ProgressRing Grid.Row="1"
IsActive="{x:Bind ViewModel.IsBusy, Mode=OneWay}"
HorizontalAlignment="Center"/>
</Grid>
</Page>
WPF 차이: WPF에는 x:Bind·ProgressRing·TitleTextBlockStyle이 없다. WPF View는 {Binding Title}(DataContext에 VM 주입), ProgressRing 대신 ProgressBar IsIndeterminate="True", namespace는 System.Windows.Controls.Page, Style은 프로젝트/WPF-UI 리소스를 사용한다. 또한 WPF는 Grid의 RowDefinitions="Auto,*" 축약 문법과 Grid Padding을 지원하지 않는다 — <Grid.RowDefinitions>를 전개해 <RowDefinition Height="Auto"/><RowDefinition Height="*"/>로 쓰고, Padding 대신 자식 요소에 Margin을 준다(또는 Grid를 Border Padding으로 감싼다).
MAUI 차이: MAUI는 Page 대신 ContentPage(Microsoft.Maui.Controls). x:Bind가 없어 {Binding Title}(BindingContext에 VM 주입, x:DataType으로 컴파일 바인딩 권장), ProgressRing 대신 ActivityIndicator, 코드비하인드 namespace는 Microsoft.Maui.Controls, DI는 MauiProgram의 builder.Services(App.GetService 대신 생성자 주입). ViewModel(Step 2)은 CommunityToolkit.Mvvm 그대로 사용한다.
코드비하인드 (<Name>Page.xaml.cs):
using Microsoft.UI.Xaml.Controls;
namespace <ProjectNamespace>.Views;
public sealed partial class <Name>Page : Page
{
public <Name>ViewModel ViewModel { get; }
public <Name>Page()
{
ViewModel = App.GetService<<Name>ViewModel>();
InitializeComponent();
Loaded += async (_, _) => await ViewModel.LoadCommand.ExecuteAsync(null);
}
}
주의: App.GetService<T>()는 App.xaml.cs에 정의된 정적 헬퍼라고 가정. 프로젝트가 다른 방식(생성자 주입, IPageFactory 등)을 쓰면 그쪽을 따른다.
Step 4. DI 등록
App.xaml.cs (또는 Program.cs) 의 ConfigureServices에 추가:
private static IServiceProvider ConfigureServices()
{
var services = new ServiceCollection();
services.AddTransient<<Name>ViewModel>();
services.AddTransient<<Name>Page>();
return services.BuildServiceProvider();
}
수명: 일반적으로 ViewModel은 Transient. 앱 전체에서 상태를 유지해야 하면 Singleton 검토.
Step 5. 네비게이션 연결 (해당하는 경우)
기존 네비게이션 패턴에 따라:
Frame.Navigate(typeof(<Name>Page))
INavigationService.NavigateTo("<Name>")
- 메뉴/사이드바에 항목 추가
이 단계는 plan.md에 명시된 진입점에 따라 진행. 추측 금지.
Step 6. 테스트 스캐폴드
tests/<Project>.Tests/ViewModels/<Name>ViewModelTests.cs:
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace <ProjectNamespace>.Tests.ViewModels;
public class <Name>ViewModelTests
{
private static <Name>ViewModel CreateSut()
{
return new <Name>ViewModel(
NullLogger<<Name>ViewModel>.Instance
);
}
[Fact]
public async Task LoadCommand_초기실행_IsBusy_가_복원된다()
{
var sut = CreateSut();
await sut.LoadCommand.ExecuteAsync(null);
Assert.False(sut.IsBusy);
}
}
Step 7. 검증
다음을 모두 통과해야 완료:
변형 (Variants)
A. ContentDialog (모달)
Page 대신 ContentDialog 사용:
public sealed partial class <Name>Dialog : ContentDialog
{
public <Name>ViewModel ViewModel { get; }
public <Name>Dialog(<Name>ViewModel viewModel)
{
ViewModel = viewModel;
InitializeComponent();
}
}
생성자 주입 가능 (DI에서 직접 해석).
B. UserControl (재사용 부품)
ViewModel을 외부(부모 View)에서 DataContext로 주입받는 형태. UserControl 내부에서는 상속된 DataContext에 {Binding}으로 바인딩하고, 자체적으로 DataContext를 덮어쓰지 않는다.
<UserControl ...>
<Grid>
...
</Grid>
</UserControl>
C. Settings / 영속화가 필요한 경우
Singleton ViewModel + ISettingsService 의존성 주입.
안티패턴 (금지)
| 안티패턴 | 올바른 행동 |
|---|
INotifyPropertyChanged 수동 구현 | [ObservableProperty] 사용 |
ICommand를 수동 구현 | [RelayCommand] 사용 |
ViewModel에서 MessageBox 직접 호출 | IDialogService 등으로 추상화 |
ViewModel에서 HttpClient 직접 사용 | Domain/Application 서비스 경유 |
| 코드비하인드에 비즈니스 로직 작성 | ViewModel로 이동 |
동기 Wait(), .Result 호출 | async/await |
LoadAsync를 생성자에서 직접 호출 | Loaded 이벤트 또는 명시적 커맨드 |
DI 등록 없이 new ViewModel()·영문 XML doc 주석 금지는 절대 규칙 3·4가 정본(중복 행 제거).
Halt 조건
다음 발견 시 사용자에게 보고하고 중지:
- 기존 ViewModel이 다른 베이스 클래스(
BindableBase, ReactiveObject 등)를 쓰고 있음
- DI 컨테이너가 없거나, ServiceLocator를 남용하고 있음(제3자 Service Locator 라이브러리·전역 정적 컨테이너를 여기저기서 직접 뒤지는 안티패턴). 단
App.GetService<T>() 같은 프로젝트 관례의 정적 헬퍼는 남용이 아니다 — WinUI 3 템플릿의 표준 패턴이므로 그대로 사용한다(생성자 주입이 기본이되, View 코드비하인드에서 VM을 얻는 App.GetService 관례는 허용). Halt는 "관례 없는 전역 로케이터 남용"에 한한다
- 네비게이션 패턴이 plan.md에 명시되지 않았고 코드베이스에서도 단일 패턴이 보이지 않음
- View가 코드 생성기로 만들어지는 경우 (
*.Generated.*)
CommunityToolkit.Mvvm 패키지가 프로젝트에 없음 — [ObservableProperty]·[RelayCommand]·ObservableObject가 컴파일되지 않는다. 의존성 추가는 승인 필요이므로 임의로 추가하지 말고 사용자에게 확인(또는 plan에 패키지 추가를 명시)